在 Java 多线程编程中,造成线程安全问题的原因主要是由于存在多条线程共同操作共享数据。解决线程安全问题的根本办法就是同一时刻有且只有一个线程在操作共享数据,其他线程必须等到该线程处理完数据后再对共享数据进行操作。
此时便引出了互斥锁,互斥锁的特性:
- 互斥性(操作的原子性):即在同一时间只允许一个线程持有某个对象锁;
- 可见性:在锁被释放之前,对共享变量所做的修改,对于随后获得该锁的另外一个线程是可见的。
synchronized同步锁
对象锁
对象锁主要有两种用法:
1.同步代码块,synchronized(this),synchronized(类实例对象),锁是小括号 () 中的实例对象。
private final Object object = new Object();
public void draw(double drawAmount) {
// 同步代码块
synchronized(this) {
}
// 同步代码块
synchronized(object) {
}
}
2.同步非静态方法,synchronized methodName,锁是当前对象的实例对象。
public synchronized void draw(double drawAmount) {
// 线程安全
}
对象锁加锁时,例如 synchronized(object),用到的是 object 对象内置的 Monitor,线程开始执行同步代码块之前,必须先获得对 Monitor 的锁定,通常推荐使用可能被并发访问的共享资源充当 Monitor。
类锁
类锁主要有两种用法:
1.同步代码块,synchronized(类.this),锁是小括号 () 中的类对象(Class 对象)。
public void draw(double drawAmount) {
// 同步代码块
synchronized(Account.this) {
// 线程安全
}
// 同步代码块结束, 该线程释放同步锁
}
2.同步静态方法,synchronized static methodName,锁是当前对象的类对象(Class 对象)。
public synchronized static void draw(double drawAmount) {
// 线程安全
}
synchronized底层实现原理
synchronized 同步锁一共有四种状态,无锁状态、偏向锁、轻量级锁、重量级锁,它会随着竞争情况逐渐升级。偏向锁和轻量级锁是 JDK1.6 对 synchronized 锁进行优化后新增加的,后面会提及。
每个对象都存在一个 Monitor 与之关联,Monitor 是每个 Java 对象天生自带的一个看不见的锁,叫做内部锁,是一种同步机制。在 HotSpot VM 中,Monitor 是由 objectMonitor.hpp 实现的,位于 HotSpot VM 源码中,是由 C++ 实现的。在 Mark Word 中,synchronized 重量级锁锁的标识位是 10,指针指向了 Monitor 对象的起始地址。
锁状态
自旋锁与自适应自旋锁
1.自旋锁
互斥同步进入阻塞状态的开销都很大,应该尽量避免。大多数情况下,共享数据的锁定状态持续时间很短。自旋锁的思想是让一个线程在请求一个共享数据的锁时执行忙循环(自旋)一段时间,不让出 CPU,如果在这段时间内能获得锁,就可以避免进入阻塞状态。
但是,如果锁会被线程占用很长时间,那么进行忙循环操作占用 CPU 时间就会造成很大的性能开销,所以自旋锁只适用于共享数据的锁定状态很短的场景。
2.自适应自旋锁
在 JDK 1.6 中引入了自适应的自旋锁。自适应意味着自旋的次数不再固定了,而是由前一次在同一个锁上的自旋次数及锁的拥有者的状态来决定。
锁消除
JIT 编译时,对运行上下文进行扫描,去除不可能存在竞争的锁。
例如 StringBuffer 是线程安全的,是因为它的 append 方法使用的是 synchronized 修饰的方法。
@Override
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
如果 StringBuffer 对象属于不可能共享的资源,那么 JVM 就会自动消除 StringBuffer 内部的锁,即 append 的 synchronized 头部。
锁粗化
通过扩大加锁的范围,避免反复加锁和解锁。
int i = 0;
StringBuffer sb = new StringBuffer();
while(i < 100) {
sb.append("target");
}
像这种连续的 append 操作,就属于反复加锁的情况,JVM 会检测到这一连串操作都对这同一个对象反复加锁解锁,此时 JVM 就会将加锁的范围粗化到这一连串操作的外部,使得只需要加一次锁就可以完成了。
偏向锁
偏向锁的核心思想:
如果一个线程获得了锁,那么锁就进入偏向模式,此时 Mark Word 的结构也变为偏向锁结果,当该线程再次请求锁时,无需再做任何同步操作,即获取锁的过程只需要检查 Mark Word 的标记位为偏向锁以及当前线程 ID 等于 Mark Word 的 ThreadID 即可,这样就省去了大量有关锁申请的操作。
偏向锁不适合锁竞争比较激烈的多线程场合。
轻量级锁
轻量级锁是由偏向锁升级而来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁竞争的时候,偏向锁就会升级为轻量级锁。
适合的场景:线程交替执行同步块的情况。
若存在同一时间访问同一锁的情况,就会导致轻量级锁升级为重量级锁。
锁状态对比
锁 | 优点 | 缺点 | 使用场景 | |
偏向锁 | 加锁和解锁不需要 CAS 操作,没有额外的性能消耗,和执行非同步方法相比仅存在纳秒级的差距。 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗。 | 只有一个线程访问同步块或者同步方法的场景。 | |
轻量级锁 | 竞争的线程不会阻塞,提高了响应速度。 | 若线程长时间抢不到锁,自旋会消耗 CPU 性能。 | 线程交替执行同步块或者同步方法的场景。 | |
重量级锁 | 线程竞争不使用自旋,不会消耗 CPU。 | 线程阻塞,响应时间缓慢,在多线程下,频繁的获取释放锁,会带来巨大的性能消耗。 | 追求吞吐量,同步块或者同步方法执行时间较长的场景。 |