JAVA 锁机制
锁要解决的是“多个线程同时访问共享可变状态”时的数据一致性问题。如果没有锁,多个线程可能同时读取旧值、覆盖彼此的写入,最终得到错误结果。Java 中常见锁机制包括 synchronized、ReentrantLock、读写锁、AQS 同步器以及 JVM 对 synchronized 做的锁优化。
为什么需要锁
flowchart TD
A["多个线程同时修改 count"] --> B["读取旧值"]
B --> C["各自计算 count + 1"]
C --> D["写回结果"]
D --> E["后写覆盖先写<br/>产生丢失更新"]
F["使用锁"] --> G["同一时间只允许一个线程进入临界区"]
G --> H["共享状态按顺序修改"]锁的本质不是“让代码变慢”,而是给共享数据划出临界区,让同一时刻只有一个线程修改它。没有共享可变状态时,不需要锁;有共享可变状态但不加锁时,结果就可能不可靠。
锁升级原理总览
flowchart TD
A["无锁"] --> B["偏向锁<br/>同一线程反复进入"]
B --> C["轻量级锁<br/>少量竞争 CAS"]
C --> D["重量级锁<br/>竞争激烈进入 Monitor"]
D --> E["阻塞和唤醒线程"]这套优化的原因是:很多同步块其实没有竞争。如果一开始就进入重量级锁,会浪费线程阻塞和唤醒成本;如果竞争真的变激烈,再升级为重量级锁保证正确性。
偏向锁
什么是偏向锁?
当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设为“01”,即偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中 ,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作,偏向锁的效率高。
偏向锁的好处?
偏向锁是在只有一个线程执行同步块时进一步提高性能,适用于一个线程反复获得同一锁的情况。偏向锁可以提高带有同步但无竞争的程序性能。
轻量级锁
什么是轻量级锁?
轻量级锁是JDK 6之中加入的新型锁机制,它名字中的“轻量级”是相对于使用monitor的传统锁而言的,因此传统的锁机制就称为“重量级”锁。首先需要强调一点的是,轻量级锁并不是用来代替重量级锁的。
引入轻量级锁的目的
在多线程交替执行同步块的情况下,尽量避免重量级锁引起的性能消耗,但是如果多个线程在同一时刻进入临界区,会导致轻量级锁膨胀升级重量级锁,所以轻量级锁的出现并非是要替代重量级锁。
轻量级锁原理
当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁,其步 骤如下: 获取锁
- 判断当前对象是否处于无锁状态(hashcode、0、01),如果是,则JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word),将对象的Mark Word复制到栈帧中的Lock Record中,将Lock Reocrd中的owner指向当前对象。
- JVM利用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,如果成功表示竞争到锁,则将锁标志位变成00,执行同步操作。
- 如果失败则判断当前对象的Mark Word是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁,锁标志位变成10,后面等待的线程将会进入阻塞状态。
轻量级锁的释放
轻量级锁的释放也是通过CAS操作来进行的,主要步骤如下:
- 取出在获取轻量级锁保存在Displaced Mark Word中的数据。
- 用CAS操作将取出的数据替换当前对象的Mark Word中,如果成功,则说明释放锁成功。
- 如果CAS操作替换失败,说明有其他线程尝试获取该锁,则需要将轻量级锁需要膨胀升级为重量级锁。
对于轻量级锁,其性能提升的依据是“对于绝大部分的锁,在整个生命周期内都是不会存在竞争的”,如果打破这个依据则除了互斥的开销外,还有额外的CAS操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢。
轻量级锁好处
在多线程交替执行同步块的情况下,可以避免重量级锁引起的性能消耗。
自旋锁
前面我们讨论monitor实现锁的时候,知道monitor会阻塞和唤醒线程,线程的阻塞和唤醒需要CPU从 用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,这些操作给系统的并发性能 带来了很大的压力。同时,虚拟机的开发团队也注意到在许多应用上,共享数据的锁定状态只会持续很 短的一段时间,为了这段时间阻塞和唤醒线程并不值得。如果物理机器有一个以上的处理器,能让两个 或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一下”,但不放弃处理器的执行 时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自 旋) , 这项技术就是所谓的自旋锁。
自旋锁在JDK 1.4.2中就已经引入 ,只不过默认是关闭的,可以使用-XX:+UseSpinning参数来开启,在 JDK 6中 就已经改为默认开启了。自旋等待不能代替阻塞,且先不说对处理器数量的要求,自旋等待本 身虽然避免了线程切换的开销,但它是要占用处理器时间的,因此,如果锁被占用的时间很短,自旋等 待的效果就会非常好,反之,如果锁被占用的时间很长。那么自旋的线程只会白白消耗处理器资源,而 不会做任何有用的工作,反而会带来性 能上的浪费。因此,自旋等待的时间必须要有一定的限度,如果 自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程了。自旋次数的默认值 是10次,用户可以使用参数-XX : PreBlockSpin来更改。
适应性自旋锁
在JDK 6中引入了自适应的自旋锁。自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上 的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持 有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持 续相对更长的时间,比如100次循环。另外,如果对于某个锁,自旋很少成功获得过,那在以后要获取 这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,随着程序运行和性能监控 信息的不断完善,虚拟机对程序锁的状况预测就会越来越准确,虛拟机就会变得越来越“聪明”了。
锁消除
锁消除是指虚拟机即时编译器(JIT)在运行时,对一些代码上要求同步,但是被检测到不可能存在共享 数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中, 堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们 是线程私有的,同步加锁自然就无须进行。变量是否逃逸,对于虚拟机来说需要使用数据流分析来确 定,但是程序员自己应该是很清楚的,怎么会在明知道不存在数据争用的情况下要求同步呢?实际上有 许多同步措施并不是程序员自己加入的,同步的代码在Java程序中的普遍程度也许超过了大部分读者的 想象。下面这段非常简单的代码仅仅是输出3个字符串相加的结果,无论是源码字面上还是程序语义上 都没有同步。
锁粗化
原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小,只在共享数据的实际作 用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线 程也能尽快拿到锁。大部分情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对 象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操 作也会导致不必要的性能损耗。
代码 Demo:synchronized 保护共享变量
下面示例中,多个线程同时累加 count。如果去掉 synchronized,结果可能小于预期。
public class LockDemo {
private int count = 0;
public synchronized void increment() {
count++;
}
public int getCount() {
return count;
}
public static void main(String[] args) throws Exception {
LockDemo demo = new LockDemo();
Thread[] threads = new Thread[10];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 10000; j++) {
demo.increment();
}
});
threads[i].start();
}
for (Thread thread : threads) {
thread.join();
}
System.out.println(demo.getCount());
}
}锁的本质是保护共享可变状态。没有共享状态时,不要为了“保险”随便加锁。
常见风险
| 风险 | 后果 | 建议 |
|---|---|---|
| 锁范围过大 | 并发度下降,吞吐降低 | 只保护真正共享的临界区 |
| 多把锁顺序不一致 | 可能死锁 | 统一加锁顺序,必要时使用超时锁 |
| 锁里执行慢 IO | 持锁时间过长,其他线程等待 | 锁内只做内存状态修改 |
| 只关注性能忽略正确性 | 数据错乱比慢更严重 | 先保证线程安全,再优化 |
| 对非共享数据加锁 | 增加无意义成本 | 明确共享边界 |
