(拉钩)Android工程师进阶34讲-08:Synchronized与ReentrantLock

1. Synchronized

Synchronized可以用来修饰以下三个层面:

  • 修饰实例方法;
  • 修饰静态类方法;
  • 修饰代码块。

1.1 Synchronized修饰实例方法

1
2
3
4
5
6
7
public class LagouSynchronizedMethods {
private int sum = 0;

public synchronized void calculate() {
sum = sum + 1;
}
}

这种情况下锁的对象是当前实例对象,因此只有同一个实例对象调用此方法才会产生效果,不同实例对象之间不会有互斥效果。如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class LagouSynchronizedMethods {
public static void main(String[] args) {
LagouSynchronizedMethods l1 = new LagouSynchronizedMethods();
LagouSynchronizedMethods l2 = new LagouSynchronizedMethods();

Thread t1 = new Thread(l1::printLog);
Thread t2 = new Thread(l2::printLog);

t1.start();
t2.start();
}

public synchronized void printLog() {
try {
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + " is printing " + i);
Thread.sleep(300);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

上述代码,在不同线程中调用的是不同对象的printLog方法,因此彼此之间不会排斥。运行结果如下:

1
2
3
4
5
6
7
8
9
10
Thread-1 is printing 0
Thread-0 is printing 0
Thread-0 is printing 1
Thread-1 is printing 1
Thread-1 is printing 2
Thread-0 is printing 2
Thread-1 is printing 3
Thread-0 is printing 3
Thread-0 is printing 4
Thread-1 is printing 4

可以看到,两个线程是交互执行的。

如果将代码修改如下,两个线程调用一个对象的printLog方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class LagouSynchronizedMethods {
public static void main(String[] args) {
LagouSynchronizedMethods l1 = new LagouSynchronizedMethods();

Thread t1 = new Thread(l1::printLog);
Thread t2 = new Thread(l1::printLog);

t1.start();
t2.start();
}

public synchronized void printLog() {
try {
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + " is printing " + i);
Thread.sleep(300);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

打印如下:

1
2
3
4
5
6
7
8
9
10
Thread-0 is printing 0
Thread-0 is printing 1
Thread-0 is printing 2
Thread-0 is printing 3
Thread-0 is printing 4
Thread-1 is printing 0
Thread-1 is printing 1
Thread-1 is printing 2
Thread-1 is printing 3
Thread-1 is printing 4

可以看出,只有当一个线程中的代码执行完毕后,才会调用另一个线程中的代码。此时两个线程间是互斥的。

1.2 Synchronized修饰静态类方法

如果synchronized修饰的是静态方法,则锁对象是当前类的Class对象,因此即使在不同线程间调用不同实例对象,也会有互斥效果。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class LagouSynchronizedMethods {
public static void main(String[] args) {
LagouSynchronizedMethods l1 = new LagouSynchronizedMethods();
LagouSynchronizedMethods l2 = new LagouSynchronizedMethods();

Thread t1 = new Thread(() -> l1.printLog());
Thread t2 = new Thread(() -> l2.printLog());

t1.start();
t2.start();
}

public static synchronized void printLog() {
try {
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + " is printing " + i);
Thread.sleep(300);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

打印如下:

1
2
3
4
5
6
7
8
9
10
Thread-0 is printing 0
Thread-0 is printing 1
Thread-0 is printing 2
Thread-0 is printing 3
Thread-0 is printing 4
Thread-1 is printing 0
Thread-1 is printing 1
Thread-1 is printing 2
Thread-1 is printing 3
Thread-1 is printing 4

两个线程是依次执行的。

1.3 Synchronized修饰代码块

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class LagouSynchronizedMethods {
private Object lock = new Object();

public static void main(String[] args) {

LagouSynchronizedMethods l1 = new LagouSynchronizedMethods();

Thread t1 = new Thread(l1::printLog);
Thread t2 = new Thread(l1::printLog);

t1.start();
t2.start();
}

public void printLog() {
synchronized (lock) {
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + " is printing " + i);
}
}
}
}

synchronized作用于代码块,锁对象就是紧跟在后面括号中的对象。上图可以看出任何Object对象都可以当做锁对象。

1.4 实现细节

synchronized既可以作用于方法,也可以作用于某一代码块。但在实现上是有区别的。比如以下代码,使用synchronized作用于代码块:

1
2
3
4
5
6
7
8
9
public class Foo {
private int number;
public void test1() {
int i = 0;
synchronized (this) {
number = i + 1;
}
}
}

使用javap查看test1方法的字节码,可以看出,编译而成的字节码中会包含monitorentermonitorexit这两个字节码指令。如下:

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
public void test1();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=3, locals=4, args_size=1
0: iconst_0
1: istore_1
2: aload_0
3: dup
4: astore_2
5: monitorenter
6: aload_0
7: iload_1
8: iconst_1
9: iadd
10: putfield #2 // Field number:I
13: aload_2
14: monitorexit
15: goto 23
18: astore_3
19: aload_2
20: monitorexit
21: aload_3
22: athrow
23: return

上面字节码中有1个monitorenter和2个monitorexit。这是因为虚拟机需要保证当异常发生时也能释放锁。因此2个monitorexit,一个是代码正常执行结束后释放锁,一个是代码执行异常时释放锁。

再来看看synchronized修饰方法有哪些区别:

1
2
3
4
5
6
public class Foo {
public synchronized void test1() {
int i = 0;
i = i + 1;
}
}

以上代码经过编译之后,字节码如下:

1
2
3
4
5
6
7
8
9
10
11
12
public synchronized void test1();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=2, args_size=1
0: iconst_0
1: istore_1
2: iload_1
3: iconst_1
4: iadd
5: istore_1
6: return

可以看出,被synchronized修饰的方法在被编译成字节码后,在方法的flags属性中会被标记为ACC_SYNCHRONIZED。当虚拟机访问一个被标记为ACC_SYNCHRONIZED的方法时,会自动在方法的开始和结束(或异常)位置添加monitorentermonitorexit指令。

关于monitorentermonitorexit,可以理解为一把具体的锁。在这个锁中保存着两个比较重要的属性:计数器和指针。

  • 计数器:表示当前线程一共访问几次这把锁。
  • 指针:指向持有这把锁的线程。

锁计数器默认为0,当执行monitorenter指令时,如锁计数器为0,说明这把锁没有被其他线程持有。那么线程会将计数器加1,并将锁中的指针指向自己。当执行monitorexit指令时,会将计数器减1。

2. ReentrantLock

2.1 ReentrantLock基本使用

ReentrantLock的用法和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
import java.util.concurrent.locks.ReentrantLock;

public class LagouReentrantLockTest {
ReentrantLock lock = new ReentrantLock();

public static void main(String[] args) {
LagouReentrantLockTest l1 = new LagouReentrantLockTest();

Thread t1 = new Thread(l1::printLog);
Thread t2 = new Thread(l1::printLog);

t1.start();
t2.start();
}

public void printLog() {
try {
lock.lock();
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + " is printing " + i);
}
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}

运行效果如下:

1
2
3
4
5
6
7
8
9
10
Thread-0 is printing 0
Thread-0 is printing 1
Thread-0 is printing 2
Thread-0 is printing 3
Thread-0 is printing 4
Thread-1 is printing 0
Thread-1 is printing 1
Thread-1 is printing 2
Thread-1 is printing 3
Thread-1 is printing 4

2.2 公平锁实现

ReentrantLock有一个带参的构造器:

1
2
3
4
5
6
7
8
9
/**
* Creates an instance of {@code ReentrantLock} with the
* given fairness policy.
*
* @param fair {@code true} if this lock should use a fair ordering policy
*/
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}

默认情况下,synchronizedReentrantLock都是非公平锁。但是ReentrantLock可以通过传入true来创建一个公平锁。所谓公平锁就是通过同步队列来实现多个线程按照申请锁的顺序获取锁。

公平锁实例:

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
import java.util.concurrent.locks.ReentrantLock;

public class LagouFairLockTest implements Runnable {
private int shareNumber = 0;

// 创建公平锁
private static ReentrantLock lock = new ReentrantLock(true);

@Override
public void run() {
while (shareNumber < 20) {
lock.lock();
try {
shareNumber++;
System.out.println(Thread.currentThread().getName() + " 获得锁,shareNumber is " + shareNumber);
} finally {
lock.unlock();
}
}
}

public static void main(String[] args) {
LagouFairLockTest lft = new LagouFairLockTest();
Thread t1 = new Thread(lft);
Thread t2 = new Thread(lft);
Thread t3 = new Thread(lft);
t1.start();
t2.start();
t3.start();
}
}

打印如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Thread-0 获得锁,shareNumber is 1
Thread-1 获得锁,shareNumber is 2
Thread-0 获得锁,shareNumber is 3
Thread-1 获得锁,shareNumber is 4
Thread-0 获得锁,shareNumber is 5
Thread-1 获得锁,shareNumber is 6
Thread-2 获得锁,shareNumber is 7
Thread-0 获得锁,shareNumber is 8
Thread-1 获得锁,shareNumber is 9
Thread-2 获得锁,shareNumber is 10
Thread-0 获得锁,shareNumber is 11
Thread-1 获得锁,shareNumber is 12
Thread-2 获得锁,shareNumber is 13
Thread-0 获得锁,shareNumber is 14
Thread-1 获得锁,shareNumber is 15
Thread-2 获得锁,shareNumber is 16
Thread-0 获得锁,shareNumber is 17
Thread-1 获得锁,shareNumber is 18
Thread-2 获得锁,shareNumber is 19
Thread-0 获得锁,shareNumber is 20
Thread-1 获得锁,shareNumber is 21
Thread-2 获得锁,shareNumber is 22

可以看出,创建的3个线程按照顺序去修改shareNumber的值。

2.2 读写锁(ReentrantReadWriteLock)

开发中,经常定义一个线程间共享的用作缓存的数据结构,比如一个较大的Map。缓存中保存了全部的城市id和城市name对应关系。这个大Map绝大部分时间提供读服务(根据城市id查询城市名称等)。而写操作占有的时间很少,通常是在服务启动时初始化,然后可以每隔一段时间再刷新缓存的数据。但是写操作开始到结束之间,不能再有其他读操作进来,并且写操作完成之后的更新数据需要对后续的读操作可见。

在没有读写锁支持的时候,如果想要实现上面的功能,需要使用Java的等待通知机制,就是当写操作开始时,所有晚于写操作的读操作都会进入等待状态,只有写操作完成并进行通知之后,所有等待的读操作才能继续执行。这样做的目的是使读操作能读取到正确的数据。

但是如果使用concurrent包中的读写锁(ReentrantReadWriteLock)实现上面的功能,就只需要在读操作时获取读锁,写操作时获取写锁即可。当写锁被获取到时,后续的读写锁都会被阻塞,写锁释放之后,所有操作继续执行,这种编程方式相对于使用等待通知机制而言,更加简明。

下面,看看读写锁(ReentrantReedWriteLock)如何使用:

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
41
42
43
44
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class LagouReentrantReadWriteLockTest {
// 创建读写锁对象
private static final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(true);
private static String number = "0";

public static void main(String[] args) {
Thread t1 = new Thread(new Reader(), "读线程 1");
Thread t2 = new Thread(new Reader(), "读线程 2");
Thread t3 = new Thread(new Writer(), "写线程");
t1.start();
t2.start();
t3.start();
}

static class Reader implements Runnable {
@Override
public void run() {
for (int i = 0; i < 10; i++) {
// 使用读锁对读操作加锁
lock.readLock().lock();
System.out.println(Thread.currentThread().getName() + " ---> Number is " + number);
lock.readLock().unlock();
}
}
}

static class Writer implements Runnable {
@Override
public void run() {
for (int i = 1; i <= 7; i += 2) {
try {
// 使用写锁对写操作加锁
lock.writeLock().lock();
System.out.println(Thread.currentThread().getName() + " is writing " + i);
number = number.concat("" + i);
} finally {
lock.writeLock().unlock();
}
}
}
}
}

打印如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
读线程 1 ---> Number is 0
读线程 2 ---> Number is 0
读线程 1 ---> Number is 0
写线程 is writing 1
读线程 2 ---> Number is 01
读线程 1 ---> Number is 01
写线程 is writing 3
读线程 2 ---> Number is 013
读线程 1 ---> Number is 013
写线程 is writing 5
读线程 2 ---> Number is 0135
读线程 1 ---> Number is 0135
写线程 is writing 7
读线程 2 ---> Number is 01357
读线程 1 ---> Number is 01357
读线程 1 ---> Number is 01357
读线程 2 ---> Number is 01357
读线程 1 ---> Number is 01357
读线程 1 ---> Number is 01357
读线程 1 ---> Number is 01357
读线程 2 ---> Number is 01357
读线程 2 ---> Number is 01357
读线程 2 ---> Number is 01357
读线程 2 ---> Number is 01357

3. 总结

Java同步实现的两个方式:

  • synchronized:使用简单,加锁和释放锁都是由虚拟机自动完成。
  • ReentrantLock:需要开发者手动加锁和释放锁。但使用场景更多,公平锁还可以通过读写锁(ReentrantReadWriteLock)来实现。
  • Copyrights © 2019-2020 Tyler Liu

请我喝杯咖啡吧~

支付宝
微信