概念
synchronized与ReentrantLock 都是java多线程编程中的重要工具,可以保证编写出的代码线程安全。 之所以存在多线程不安全,是因为一个变量,在多个线程,多个CPU高速缓存的共同作用下,导致与主内存的缓存不一致,从而导致读写结果达不到预期效果。JMM (Java内存模型)规范中规定了 happends-before原则在某些特定情况下默认了操作的可见性,但是这些特定情况以外的情境下则无法保证,所以JVM中提供了 volatile 和 synchronized 关键字,确保了 对变量操作的可见性。
但 同步关键字 并不能适用于所有的编程场景,于是 ReentrantLock 可重入锁 应运而生。
Synchronized 关键字
它可以修饰的有三:
实例方法
这种情况下,锁定的是当前的实例对象,也就是说,只有同一个对象调用这个方法,才会有互斥效果。不同实例之间没有互斥效果。
java
// SynchronizedTest类
public class SynchronizedTest {
public synchronized void test() {
for (int i = 0; i < 10; i++) {
System.out.println(Thread.currentThread().getName() + "--> " + i);
try {
Thread.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
// main类
public class Main {
public static void main(String[] args) {
SynchronizedTest s1 = new SynchronizedTest();
SynchronizedTest s2 = new SynchronizedTest();
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
s1.test();
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
s2.test();
}
});
t1.start();
t2.start();
}
}
打印结果为:
java
Thread-0--> 0
Thread-1--> 0
Thread-0--> 1
Thread-1--> 1
Thread-0--> 2
Thread-1--> 2
Thread-0--> 3
Thread-1--> 3
Thread-0--> 4
Thread-1--> 4
Thread-0--> 5
Thread-1--> 5
Thread-1--> 6
Thread-0--> 6
Thread-0--> 7
Thread-1--> 7
Thread-0--> 8
Thread-1--> 8
Thread-0--> 9
Thread-1--> 9
可以看出,两个线程分别完整打印了0到9。 这是因为他们持有的是两个不同的锁对象(即2个不同的SynchronizedTest实例)而如果他们持有的是同一个锁,那么只有在一个线程释放锁之后,另一个线程才能持有锁。
代码稍作修改:
java
// SynchronizedTest类
public class SynchronizedTest {
public synchronized void test() {
for (int i = 0; i < 10; i++) {
System.out.println(Thread.currentThread().getName() + "--> " + i);
try {
Thread.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
// main类
public class Main {
public static void main(String[] args) {
SynchronizedTest s1 = new SynchronizedTest();
SynchronizedTest s2 = new SynchronizedTest();
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
s1.test();
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
s1.test(); // 都使用s1对象
}
});
t1.start();
t2.start();
}
}
那么,打印的结果将会是:
java
Thread-0--> 0
Thread-0--> 1
Thread-0--> 2
Thread-0--> 3
Thread-0--> 4
Thread-0--> 5
Thread-0--> 6
Thread-0--> 7
Thread-0--> 8
Thread-0--> 9
Thread-1--> 0
Thread-1--> 1
Thread-1--> 2
Thread-1--> 3
Thread-1--> 4
Thread-1--> 5
Thread-1--> 6
Thread-1--> 7
Thread-1--> 8
Thread-1--> 9
交替打印,符合期望。
静态方法
锁对象为当前的class对象,也就是说,与对象无关了,只要是同一个类,就有互斥效果。
稍作修改,将 test方法改为静态的:
java
// SynchronizedTest类
public class SynchronizedTest {
public static synchronized void test() {
for (int i = 0; i < 10; i++) {
System.out.println(Thread.currentThread().getName() + "--> " + i);
try {
Thread.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
// main类
public class Main {
public static void main(String[] args) {
SynchronizedTest s1 = new SynchronizedTest();
SynchronizedTest s2 = new SynchronizedTest();
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
s1.test();
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
s2.test();
}
});
t1.start();
t2.start();
}
}
打印结果也是两个线程 ,一个打印完了,另一个才开始打印。
java
Thread-0--> 0
Thread-0--> 1
Thread-0--> 2
Thread-0--> 3
Thread-0--> 4
Thread-0--> 5
Thread-0--> 6
Thread-0--> 7
Thread-0--> 8
Thread-0--> 9
Thread-1--> 0
Thread-1--> 1
Thread-1--> 2
Thread-1--> 3
Thread-1--> 4
Thread-1--> 5
Thread-1--> 6
Thread-1--> 7
Thread-1--> 8
Thread-1--> 9
实例代码块
如果修饰的是代码块的话,那么锁对象,就是跟随在 synchronized后面的对象:
java
public class SynchronizedTest {
private static Object lock = new Object();
public void test() {
synchronized (lock) {
for (int i = 0; i < 10; i++) {
System.out.println(Thread.currentThread().getName() + "--> " + i);
try {
Thread.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
public class Main {
public static void main(String[] args) {
SynchronizedTest s1 = new SynchronizedTest();
SynchronizedTest s2 = new SynchronizedTest();
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
s1.test();
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
s2.test();
}
});
t1.start();
t2.start();
}
}
上面的代码中,我指定了锁对象为一个静态对象,这种效果就和 直接synchronized修饰方法类似了。效果也一样:
lua
```java
Thread-0--> 0
Thread-0--> 1
Thread-0--> 2
Thread-0--> 3
Thread-0--> 4
Thread-0--> 5
Thread-0--> 6
Thread-0--> 7
Thread-0--> 8
Thread-0--> 9
Thread-1--> 0
Thread-1--> 1
Thread-1--> 2
Thread-1--> 3
Thread-1--> 4
Thread-1--> 5
Thread-1--> 6
Thread-1--> 7
Thread-1--> 8
Thread-1--> 9
而如果我不想用静态变量作为锁,又想达到上面同样的效果,那么就可以用 同一个SynchronizedTest
来执行test方法:
java
public class SynchronizedTest {
private Object lock = new Object();
public void test() {
synchronized (lock) {
for (int i = 0; i < 10; i++) {
System.out.println(Thread.currentThread().getName() + "--> " + i);
try {
Thread.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
public class Main {
public static void main(String[] args) {
SynchronizedTest s1 = new SynchronizedTest();
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
s1.test();
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
s1.test();
}
});
t1.start();
t2.start();
}
}
字节码细节
修饰代码块
当synchronized修饰代码块时,比如如下代码:
java
public class Foo{
private int number;
public void test1(){
int i = 0;
synchronized(this){
number = i+1;
}
}
}
javac
先编译出他的 class
文件,在用javap -c
反编译字节码,看到它的字节码内容如下:
java
public class com.example.Foo {
public com.example.Foo();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public void test1();
Code:
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
Exception table:
from to target type
6 15 18 any
18 21 18 any
}
上面可以看到 上面的23个字节码指令中, 有1个 monitorenter 和 2个monitorexit 指令。
一个enter 通常对应一个 exit,可是为什么这里多出来一个呢? 这是由于,虚拟机必须保证,在程序发生异常时,锁也能正常释放。
修饰方法时
源代码
java
package com.example;
public class Foo {
private int number;
public synchronized void test1() {
int i = 0;
number = i + 1;
}
}
字节码:
java
public class com.example.Foo {
public com.example.Foo();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public synchronized void test1();
Code:
0: iconst_0
1: istore_1
2: aload_0
3: iload_1
4: iconst_1
5: iadd
6: putfield #2 // Field number:I
9: return
}
字节码中,直接在 方法上加上了 synchronized ,这种情况下,没有显式的 monitorenter 和 monitorexit,而是会默认在 方法的开始和结束上分别添加 monitorenter 和 monitorexit。
monitorenter 和 monitorexit
这两个指令都涉及到了 monitor,它可以解释为一把具体的锁,它保存了两个重要的属性, 一个是 计数器,表示当前线程一共访问了几次这个锁 一个是 指针,指向持有当前锁的线程
上图表示了monitor的工作流程,当一个线程执行 enter指令时,monitor的计数器+1,同时,指针指向这个线程。 当一个线程执行 exit时,计数器-1,指针清零,不指向任何线程。
ReentrantLock 可重入锁
基本使用
java
package com.example;
import java.util.concurrent.locks.ReentrantLock;
public class Main {
ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) {
Main main = new Main();
Thread t1 = new Thread(new Runnable(){
@Override
public void run() {
main.printLog();
}
});
Thread t2 = new Thread(new Runnable(){
@Override
public void run() {
main.printLog();
}
});
t1.start();
t2.start();
}
private void printLog() {
try {
lock.lock();
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + "->" + i);
}
} catch (Exception e) {
} finally {
lock.unlock();
}
}
}
打印结果:
java
Thread-0->0
Thread-0->1
Thread-0->2
Thread-0->3
Thread-0->4
Thread-1->0
Thread-1->1
Thread-1->2
Thread-1->3
Thread-1->4
第一个线程持有了所,在执行完循环打印之后才释放锁,然后才轮到第二个线程来持有。也能达到与 synchronize一样的效果。
但是注意一个细节: 释放锁的操作, 放在了 try 代码块的 finally中 ,这是为了在程序发生异常时也能正常释放锁
。
公平锁
以下是 ReentrantLock的部分源代码:
java
/**
* Creates an instance of {@code ReentrantLock}.
* This is equivalent to using {@code ReentrantLock(false)}.
*/
public ReentrantLock() {
sync = new NonfairSync();
}
/**
* 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();
}
可以看到有个构造函数的重载,其中有一个带入参:fair,我们用默认的无参构造函数会创建出一个非公平锁,如果传入true作为参数,那么就是创建出一个公平锁。
如下代码:
java
package com.example;
import java.util.concurrent.locks.ReentrantLock;
public class Main {
private int shareInt = 0;
ReentrantLock lock = new ReentrantLock(true);
private void printLog() {
while (shareInt < 20) {
lock.lock();
try {
shareInt++;
System.out.println(Thread.currentThread().getName() + "->" + (shareInt));
} catch (Exception e) {
} finally {
lock.unlock();
}
}
}
public static void main(String[] args) {
Main main = new Main();
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
main.printLog();
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
main.printLog();
}
});
Thread t3 = new Thread(new Runnable() {
@Override
public void run() {
main.printLog();
}
});
t1.start();
t2.start();
t3.start();
}
}
公平锁的效果为:顾名思义,对于每一个线程,这把锁都是公平对待的。在多个线程请求锁的时候,按照他们发出请求的顺序来获得锁,公平锁会维护一个等待队列,新的线层会按照排队顺序来进行(获得锁,执行代码,释放锁)3个过程。
与之相对的就是默认的非公平锁,不会按照线程请求的顺序来获得锁,非公平锁可以通过插队机制,允许后来先到,从而提高整体的吞吐量。
两者的区别是,公平锁可以保证了请求锁的顺序和获得锁的顺序相同,带来的不良后果是,CPU会在多个线程中来回切换,算是牺牲了性能。非公平锁则没有排队机制,减少了 CPU来回切换,但是有可能导致某些线程长时间等到。 总之这两种锁,用于两种不同的业务场景,各有千秋。
读写锁
业务开发中,经常遇到 多个线程共享一个用于缓存的数据结构,比如 一个可能会随着app运行而膨胀的map,某些操作会往这个map中添加一个键值对,另一些操作则只会去从map中获取某一个key对应的value。
并且大部分情况下,是读的操作多,写的操作少。这种情况下,就要求 写的操作一定要对读的操作可见。
这是典型的生产消费者模型,消费必须以已经存在的产品作为前提,如果没有产品了,消费就会等待。
ReentrantLock提供了一个读写锁可以解决此场景下的代码写法。 如果没有这个读写锁,我们的写法可能是利用 线程的wait和notify(等待通知机制)来手动控制。
但是今天有了读写锁,则可以优化成以下写法:
java
package com.example;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class Main {
private static int shareInt = 0;
static ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
static class Reader implements Runnable {
@Override
public void run() {
for (int i = 0; i < 10; i++) {
ReentrantReadWriteLock.ReadLock readLock = lock.readLock();
try {
readLock.lock();
System.out.println(Thread.currentThread().getName() + " - " + shareInt);
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
readLock.unlock();
}
}
}
}
static class Writer implements Runnable {
@Override
public void run() {
for (int i = 0; i < 7; i += 2) {
ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();
try {
writeLock.lock();
System.out.println(Thread.currentThread().getName() + " -正在写入 " + shareInt);
Thread.sleep(200);
shareInt += 2;
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
writeLock.unlock();
}
}
}
}
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();
t3.start();
t2.start();
}
}
注意几个细节:
- shareInt为模拟的共享数据,模拟的是 必须等写入完成之后,读的操作才能继续
- 创建了2个读线程和1个写线程
- 无论是读还是写,都要先获取对应的锁,操作完成之后再释放锁
运行的结果为:
java
读线程1 - 0
读线程2 - 0
写线程 -正在写入 0
写线程 -正在写入 2
写线程 -正在写入 4
写线程 -正在写入 6
读线程1 - 8
读线程2 - 8
读线程1 - 8
读线程2 - 8
读线程1 - 8
读线程2 - 8
读线程2 - 8
读线程1 - 8
读线程2 - 8
读线程1 - 8
读线程2 - 8
读线程1 - 8
读线程2 - 8
读线程1 - 8
读线程2 - 8
读线程1 - 8
读线程2 - 8
读线程1 - 8
可以看到,当写操作在进行时,写操作都在等待,直到写操作释放了写锁,这样就保证了 后续的读操作都是取的最新的共享变量的值。
总结
Java中两种保证线程同步的方式,synchronize 、 ReentrantLock 都能保证线程安全。
前者 由JVM自动完成加锁和释放锁的操作,而后者的加锁释放锁都需要开发者手动编码,显然后者在复杂的业务场景中更加实用,公平锁和读写锁都各自解决了一部分典型的应用场景问题。