Java中对线程的同步和互斥有两种方式:使用synchronized关键字和使用ReentranLock 。也有其他的实现比如信号量,在操作系统里也有学到。

Java并发-目录

临界区管理的问题

临界区是多个并发的进程中访问共享变量的代码段。如果多个线程同步访问一个共享变量,而不加以限制,会产生和时间相关的错误,导致结果不一致。

比如一个售票系统,它的简单代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
static class WrongTicket implements Runnable {
int num = 100;

@Override
public void run() {
while (true) {
if (num > 0) {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + ": sale " + num--);
} else {
break;
}
}
}

public static void main(String[] args) {
WrongTicket ticket = new WrongTicket();
(new Thread(ticket)).start();
(new Thread(ticket)).start();
(new Thread(ticket)).start();
(new Thread(ticket)).start();
}
}

输出结果可能会是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
Thread-3: sale 10
Thread-0: sale 9
Thread-1: sale 8
Thread-2: sale 7
Thread-0: sale 6
Thread-3: sale 6
Thread-1: sale 5
Thread-2: sale 4
Thread-1: sale 3
Thread-3: sale 1
Thread-0: sale 3
Thread-2: sale 2

Thread-1和Thread-0卖出来同一张票,这显然是不合理的。

也可能出现这种情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
Thread-0: sale 10
Thread-2: sale 9
Thread-3: sale 8
Thread-1: sale 7
Thread-0: sale 6
Thread-2: sale 5
Thread-3: sale 4
Thread-1: sale 3
Thread-0: sale 2
Thread-2: sale 1
Thread-3: sale 0
Thread-1: sale -1
Thread-0: sale -2

在票卖完之后,还继续输出,导致结果是负数。

导致这些结果的原因是,没有对num这个共享变量的访问加以限制,如果在同一时刻只有一个进程可以访问共享变量,并且在结束之前把共享变量的值同步到主内存。另一个进程开始后从主内存读取共享变量。这样就可以保证数据的一致性,或者说是内存的可见性。解决这个问题的方法有两个:synchronizedReentrantLock

synchronized

synchronized是Java中的关键字,在访问临界区之前当前线程需要获得对象锁,也就是对象的monitor。

synchronized的使用方式主要为代码块同步和方法同步。

synchronized的使用

代码块同步

  • 对象同步

只有对同一个对象实例执行这段代码时,才能达到互斥访问的效果,因为锁住的是这一个对象。

1
2
3
synchronized(this) {

}
  • 类同步

锁住的是类,即使存在多个对象实例,也可以互斥的访问这段代码。

1
2
3
synchronized(SynchronizedTest.class) {

}

方法同步

  • 对象同步
1
synchronized void method1() {}
  • 类同步
1
static synchronized void method2() {}

synchronized的解决方案

只要对临界区这段代码加上代码块同步即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
static class Ticket implements Runnable {
int num = 100;

@Override
public void run() {
while (true) {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
// lock this Object
synchronized (this) {
if (num > 0) {
System.out.println(Thread.currentThread().getName() + ": sale " + num--);
} else {
break;
}
}
}
}

public static void main(String[] args) {
Ticket ticket = new Ticket();
(new Thread(ticket)).start();
(new Thread(ticket)).start();
(new Thread(ticket)).start();
(new Thread(ticket)).start();
}
}

synchronized 原理

代码块同步和方法同步,两者虽然实现细节不同,但本质上都是对一个对象的监视器(monitor)的获取。任意一个对象都拥有自己的监视器,当同步代码块或同步方法时,执行方法的线程必须先获得该对象的监视器才能进入同步块或同步方法,没有获取到监视器的线程将会被阻塞,并进入同步队列,状态变为BLOCKED。当成功获取监视器的线程释放了锁后,会唤醒阻塞在同步队列的线程,使其重新尝试对监视器的获取。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
{
public moe.leer.javareview.concurrent.Test();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 7: 0

public void method1();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: aload_1
5: monitorexit
6: goto 14
9: astore_2
10: aload_1
11: monitorexit
12: aload_2
13: athrow
14: return

public synchronized void method2();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=0, locals=1, args_size=1
0: return
LineNumberTable:
line 16: 0
}

同步方法通过ACC_SYNCHRONIZED关键字隐式的对方法进行加锁。当线程要执行的方法被标注上ACC_SYNCHRONIZED时,需要先获得锁才能执行该方法。

同步代码块通过monitorentermonitorexit执行来进行加锁。当线程执行到monitorenter的时候要先获得所锁,才能执行后面的方法。当线程执行到monitorexit的时候则要释放锁。

每个对象自身维护这一个被加锁次数的计数器,当计数器数字为0时表示可以被任意线程获得锁。当计数器不为0时,只有获得锁的线程才能再次获得锁。即可重入锁。

  • synchronized 为什么反编译后 只有一个moniter enter,但是却有两个moiter exit?

    个人想法(没有得到验证):为了防止临界区中的代码段发生异常,从而没有执行monitorexit就退出了,会造成死锁。

ReentrantLock

A reentrant mutual exclusion {@link Lock} with the same basic
behavior and semantics as the implicit monitor lock accessed using
{@code synchronized} methods and statements, but with extended
capabilities.

上面的描述在ReentrantLock源码的注释中。一个可重入锁和synchronized关键字实现的基本功能类似,都可以获取对象的monitor,来实现互斥访问。ReentrantLock在此基础上扩展了一些功能。

ReentrantLock 的使用

1
2
3
4
5
6
7
8
9
10
ReentrantLock lock = new ReentrantLock();

publick void method1() {
try {
lock.lock();
//do something
} finally {
lock.unlock();
}
}

ReentrantLock的解决方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
static class LockTicket implements Runnable {
int num = 100;
ReentrantLock lock = new ReentrantLock();

@Override
public void run() {
while (true) {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
if (num > 0) {
lock.lock();
System.out.println(Thread.currentThread().getName() + ": sale " + num--);
lock.unlock();
} else {
break;
}
}
}

public static void main(String[] args) {
LockTicket ticket = new LockTicket();
(new Thread(ticket)).start();
(new Thread(ticket)).start();
(new Thread(ticket)).start();
(new Thread(ticket)).start();
}
}

可重入

synchronizedReentrantLock都是可重入的。那么什么是可重入呢?

就是如果已经获得这把锁,那么可以再次获取。

  • synchronized
1
2
3
4
5
6
7
8
9
10
11
12
13
public void method1() {
synchronized (SynchronizedTest.class) {
System.out.println("method1 get lock");
method2();
}
}

//可重入锁:已经获得锁的进程可以再次获得这把锁。
public void method2() {
synchronized (SynchronizedTest.class) {
System.out.println("method2 get lock again");
}
}
  • ReentrantLock
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void method1() {
try {
lock.lock();
System.out.println("method1 get lock");
method2();
} finally {
lock.unlock();
}
}

//可重入锁:已经获得锁的进程可以再次获得这把锁。
public void method2() {
try {
lock.lock();
System.out.println("method2 get lock");
} finally {
lock.unlock();
}
}

synchronized 和 ReentrantLock 的区别

1)Lock是一个接口,而synchronized是Java中的关键字,synchronized是内置的语言实现;

2)synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而Lock在发生异常时,如果没有主动通过unLock()去释放锁,则很可能造成死锁现象,因此使用Lock时需要在finally块中释放锁;

3)Lock可以让等待锁的线程响应中断,而synchronized却不行,使用synchronized时,等待的线程会一直等待下去,不能够响应中断;

4)通过Lock可以知道有没有成功获取锁,而synchronized却无法办到。

5)Lock可以提高多个线程进行读操作的效率。

参考