锁的出现就是为了避免在多个线程并发访问同一个资源时出现异常情况。如果对多线程还不了解,可以看一看《Java 多线程详解》这篇文章。
Java 提供了两种锁机制来控制多个线程对共享资源的互斥访问,第一个是 JVM 实现的 synchronized,而另一个是 JDK 实现的 ReentrantLock。
一、synchronized
说起 Java 中的锁,第一反应就是 synchronized,我们可以把这个关键字放到方法或者代码块上,表示使用某一个对象作为锁,去同步这个方法或者代码块。在 Java 中任何非空对象都可以作为一把锁给 synchronized 使用。多个线程只有去访问同一个监视锁保护的临界区时才会发生竞争。
1、synchronized 基本属性
synchronized 是一种互斥锁,一次只能允许一个线程进入被锁住的代码块,它也是一种内置锁/监视器锁,Java 中每个对象都有一个内置锁(监视器,也可以理解成锁标记),而 synchronized 就是使用对象的内置锁(监视器)来将代码块/方法锁定的。
synchronized 保证了可见性,当执行完 synchronized 之后,修改后的变量对其他的线程是可见的。
synchronized 是可重入的,当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入。例如下面的代码属于重入,而 synchronized 是可重入的,所以不会产生死锁。
void test() {
synchronized (this) {
System.out.println("hello");
synchronized (this) {
System.out.println("world");
}
}
}
2、synchronized 实现原理
synchronized 是悲观锁,在字节码层被映射成两个指令:monitorenter 和 monitorexit,当一个线程遇到 monitorenter 指令时,会尝试去获取锁,如果获取成功,锁的数量 +1,(因为synchronized是一个可重入锁,需要使用锁计数来判断锁的情况),如果没有获取到锁,就会阻塞;当线程遇到 monitorexit 指令时,锁计数 -1,当计数器为 0 时,线程释放锁;如果线程遇到异常,也会释放锁。
例如查看下面代码的字节码:
public class APP {
void test() {
synchronized (this) {
System.out.println("hello world");
}
}
}
首先 cd 到文件目录,然后执行 javac APP.java,可得到字节码文件:APP.class,再执行 javap -verbose APP.class,可看到字节码内容。
字节码
二、synchronized 锁优化
在早期的 Java 版本中,synchronized 属于重量级锁,效率低下,因为监视器锁(Monitor)是依赖于低层的操作系统的 Mutex Lock 来实现的。
而操作系统实现线程中的切换时,需要从用户态切换到核心态,这是一个非常重的操作,时间成本较高。这也是早期 synchronized 效率低下的原因。
JDK 1.6 之后官方对锁做了较大优化,引入了:
适应性自旋(Adaptive Spinning)
锁消除(Lock Elimination)
锁粗化(Lock Coarsening)
同时增加了两种锁的状态:
偏向锁(Biased Locking)
轻量锁(Lightweight Locking)
先说说锁优化:
1、自旋锁(Spinning)
互斥同步进入阻塞状态的开销都很大,应该尽量避免。在实际应用中,锁定状态只会持续很短的一段时间。自旋锁的思想是让一个线程在请求一个共享数据的锁时执行忙循环(自旋)一段时间,如果在这段时间内能获得锁,就可以避免进入阻塞状态。
自旋锁虽然能避免进入阻塞状态从而减少开销,但是它需要进行忙循环操作占用 CPU 时间,它只适用于共享数据的锁定状态很短的场景。
在 JDK 1.6 中引入了自适应的自旋锁。自适应意味着自旋的次数不再固定了,而是由前一次在同一个锁上的自旋次数及锁的拥有者的状态来决定。
2、锁消除(Lock Elimination)
锁消除是指对于被检测出不可能存在竞争的共享数据的锁进行消除。
锁消除主要是通过逃逸分析来支持,如果堆上的共享数据不可能逃逸出去被其它线程访问到,那么就可以把它们当成私有数据对待,也就可以将它们的锁进行消除。
对于一些看起来没有加锁的代码,其实隐式的加了很多锁。例如下面的字符串拼接代码就隐式加了锁:
String concatString(String s1, String s2, String s3) {
return s1 + s2 + s3;
}
String 是一个不可变的类,编译器会对 String 的拼接自动优化。在 JDK 1.5 之前,会转化为 StringBuffer 对象的连续 append() 操作:
String concatString(String s1, String s2, String s3) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
每个 append() 方法中都有一个同步块。虚拟机观察变量 sb,很快就会发现它的动态作用域被限制在 concatString() 方法内部。也就是说,sb 的所有引用永远不会逃逸到 concatString() 方法之外,其他线程无法访问到它,因此可以进行锁消除。
3、锁粗化(Lock Coarsening)
如果一系列的连续操作都对同一个对象反复加锁和解锁,频繁的加锁操作就会导致性能损耗。
上一段的示例代码中连续的 append() 方法就属于这类情况。如果虚拟机探测到由这样的一串零碎的操作都对同一个对象加锁,将会把加锁的范围扩展(粗化)到整个操作序列的外部。对于上一段的示例代码就是扩展到第一个 append() 操作之前,直至最后一个 append() 操作之后,这样只需要加锁一次就可以了。
再说说锁的状态:
锁的状态共有四种:无锁,偏向锁,轻量锁,重量锁。随着锁的竞争,锁会从偏向锁升级为轻量锁,然后升级为重量锁。锁的升级是单向的,JDK 1.6 中默认开启偏向锁和轻量锁。
1、无锁(Lock Free)
无锁的执行者:CAS(Compare And Swap),即比较交换,也是自旋锁或乐观锁的核心操作。
它的实现很简单,就是用一个预期的值 E 和内存值 V 进行比较,如果两个值相等 E = V,说明该值没有被其它线程修改,就用新值 N 替换内存值 V,并返回 true。否则,说明已经被其他线程修改过,返回 false,说明已经被其他线程修改了。
基于这样的算法,CAS 即使没有锁,也可以发现其他线程对当前线程的干扰,并进行恰当的处理。
并且,CAS 是原子操作,这是由于 unsafe 为我们提供了硬件级别的原子操作。
ABA 问题:CAS 存在一个问题,就是一个值从 A 变为 B ,又从 B 变回了 A,这种情况下,CAS 会认为值没有发生过变化,但实际上是有变化的。
ABA 问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加 1,那么 A-B-A 就会变成 A1-B2-A3。
2、偏向锁(Biased Locking)
大多数情况下,锁总是由同一个线程多次获得,因此为了减少同一线程获取锁的代价而引入了偏向锁。
如果一个线程获取到了锁,那么该锁就进入偏向锁模式,当该线程再次请求锁时,无需做任何同步操作,直接获取到锁。这样就省去了大量有关锁申请的操作,提升了程序性能。
当有另外一个线程去尝试获取这个锁对象时,偏向状态就宣告结束,此时撤销偏向(Revoke Bias)后恢复到未锁定状态或者轻量级锁状态。
由此可见,偏向锁其实不适合锁竞争比较激烈的场合。
3、轻量级锁(Lightweight Locking)
轻量级锁是相对于传统的重量级锁而言,它使用 CAS 操作来避免重量级锁使用互斥量的开销。对于绝大部分的锁,在整个同步周期内都是不存在竞争的,因此也就不需要都使用互斥量进行同步,可以先采用 CAS 操作进行同步,如果 CAS 失败了再改用互斥量进行同步。
看一看 AtomicInteger 当中常用的自增方法 incrementAndGet:
public final int incrementAndGet() {
for (;;) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next))
return next;
}
}
private volatile int value;
public final int get() {
return value;
}
这段代码是一个无限循环,也就是 CAS 的自旋。循环体当中做了三件事:
获取当前值。
当前值 +1,计算出目标值。
进行 CAS 操作,如果成功则跳出循环,如果失败则重复上述步骤。
使用这种轻量级锁,来保证多线程中对同一个对象的自增操作的正确性,比直接使用 synchronized 加锁的消耗会小很多。
优缺点对比:
锁
优点
缺点
适用场景
偏向锁
加解锁不需要 CAS,无额外消耗
若竞争的线程多,会带来额外锁撤销消耗
只有一个线程或者切换不频繁
轻量级锁
竞争的线程不会阻塞,提高了相应速度
若长时间抢不到锁,会自旋消耗 CPU 性能
少量线程竞争,线程持有锁的时间短
重量级锁
线程竞争不使用自旋,不消耗 CPU
线程阻塞,多线程下频繁获取/释放锁,增加性能消耗
大量线程竞争,追求吞吐量,同步方法执行时间较长
三、ReentrantLock
ReentrantLock 是一个互斥锁,也是一个可重入锁(reentrant就是再次进入的意思)。ReentrantLock 锁在同一个时间点只能被一个线程锁持有,但是它可以被单个线程多次获取,每获取一次 AQS 的 state 就加 1,每释放一次 state 就减 1。还记得 synchronized 嘛,它也是可重入的,一个同步方法调用另外一个同步方法是没有问题的。
ReentrantLock 是 java.util.concurrent 包下提供的一套互斥锁,相比 synchronized,ReentrantLock 类提供了一些高级功能,主要有以下 3 项:
等待可中断,持有锁的线程长期不释放的时候,正在等待的线程可以选择放弃等待,这相当于 synchronized 来说可以避免出现死锁的情况。通过 lock.lockInterruptibly() 来实现这个机制。
公平锁,多个线程等待同一个锁时,必须按照申请锁的时间顺序获得锁,synchronized 锁非公平锁,ReentrantLock 默认的构造函数是创建的非公平锁,可以通过参数true设为公平锁,但公平锁表现的性能不是很好。
锁绑定多个条件,一个 ReentrantLock 对象可以同时绑定对个对象。ReenTrantLock 提供了一个 Condition(条件)类,用来实现分组唤醒需要唤醒的线程们,而不是像 synchronized 要么随机唤醒一个线程要么唤醒全部线程。
synchronized 与 ReentrantLock 的区别:
synchronized 是关键字,ReentrantLock 是类;
ReentrantLock 可以对获取锁的等待时间进行设置,避免死锁,synchronized 不行;
ReentrantLock 可以获取锁的信息,synchronized 不行;
ReentrantLock 可以灵活的实现多路通知;
ReentrantLock 调用 Unsafe 类的 park() 方法来实现锁,synchronized 操作 Mark Word 实现锁。
四、死锁
线程死锁是指由于两个或者多个线程互相持有对方所需要的资源,导致这些线程处于等待状态,若无外力作用,它们都将无法再向前推进。
例如,当线程进入对象的 synchronized 代码块时,便占有了资源,直到它退出该代码块或者调用 wait 方法,才释放资源,在此期间,其他线程将不能进入该代码块。当线程互相持有对方所需要的资源时,会互相等待对方释放资源,如果线程都不主动释放所占有的资源,将产生死锁。
下面的代码中,thread 1 持有 o1,想获取 o2,而 thread 2 持有 o2,想获取 o1,两个线程互相持有对方所需要的资源,导致这些线程处于等待状态,这样就形成了死锁。
public static void main(String[] args) {
final Object o1 = new Object();
final Object o2 = new Object();
new Thread(() -> {
synchronized (o1) { // thread 1 持有 o1
System.out.println("thread 1 get o1");
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (o2) { // thread 1 想获取 o2
System.out.println("thread 1 get o2");
}
}
}).start();
new Thread(() -> {
synchronized (o2) { // thread 2 持有 o2
System.out.println("thread 2 get o1");
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (o1) { // thread 2 想获取 o1
System.out.println("thread 2 get o2");
}
}
}).start();
}
当然死锁的产生是必须要满足一些特定条件的:
互斥条件:进程对于所分配到的资源具有排它性,即一个资源只能被一个进程占用,直到被该进程释放;
请求和保持条件:一个进程因请求被占用资源而发生阻塞时,对已获得的资源保持不放;
不可剥夺条件:任何一个资源在没被该进程释放之前,任何其他进程都无法对他剥夺占用;
循环等待条件:当发生死锁时,所等待的进程必定会形成一个环路(类似于死循环),造成永久阻塞。
解决死锁的基本方法:
资源一次性分配:一次性分配所有资源,这样就不会再有请求了(破坏请求条件);
只要有一个资源得不到分配,也不给这个进程分配其他的资源(破坏请保持条件);
可剥夺资源:即当某进程获得了部分资源,但得不到其它资源,则释放已占有的资源(破坏不可剥夺条件);
资源有序分配法:系统给每类资源赋予一个编号,每一个进程按编号递增的顺序请求资源,释放则相反(破坏环路等待条件);
加锁时限:线程尝试获取锁的时候加上一定的时限,超过时限则放弃对该锁的请求,并释放自己占有的锁。