在 Java 多线程编程中,造成线程安全问题的原因主要是由于存在多条线程共同操作共享数据。解决线程安全问题的根本办法就是同一时刻有且只有一个线程在操作共享数据,其他线程必须等到该线程处理完数据后再对共享数据进行操作。
此时便引出了互斥锁,互斥锁的特性:
只有共享资源的读写访问才需要同步化。发生脏读的情况是在读取实例变量时,此值已经被其他线程更改过了。
synchronized 同步锁特点:
根据获取的锁的分类,可以分为对象锁和类锁。
对象锁主要有两种用法:
private final Object object = new Object();
public void draw(double drawAmount) {
// 同步代码块
synchronized(this) {
}
// 同步代码块
synchronized(object) {
}
}
public synchronized void draw(double drawAmount) {
// 线程安全
}
对象锁加锁时,例如 synchronized(object),用到的是 object 对象内置的 Monitor,线程开始执行同步代码块之前,必须先获得对 Monitor 的锁定,通常推荐使用可能被并发访问的共享资源充当 Monitor。
线程会在如下几种情况释放对 Monitor 的锁定:
如下几种情况线程不会释放对 Monitor 的锁定:
类锁主要有两种用法:
public void draw(double drawAmount) {
// 同步代码块
synchronized(Account.this) {
// 线程安全
}
// 同步代码块结束, 该线程释放同步锁
}
public synchronized static void draw(double drawAmount) {
// 线程安全
}
对象锁和类锁的总结:
synchronized 还拥有锁重入的功能,自己可以再次获取自己的内部锁。比如有 1 条线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重入的话,就会造成死锁。
死锁:当两个线程相互等待对方释放同步监视器时就会发生死锁。
可变类的线程安全是以降低程序的运行效率作为代价的,为了减少线程安全所带来的负面影响,程序可以采用如下策略:
Java 对象头和 Monitor 是实现 synchronized 的基础。HotSpot VM 对象在内存中的布局分为:对象头、实例数据、对齐填充。
Java 对象头由 Mark Word 和 Class Metadata Address 组成:
JVM位数 | 对象头结构 | 说明 |
---|---|---|
32/64 bit | Mark Word | 默认存储对象的 hashCode,分代年龄,锁类型,锁标志位等信息。 |
32/64 bit | Class Metadata Address | 类型指针指向对象的类元数据,JVM 通过这个指针确定该对象是哪个类的数据。 |
由于 Java 对象头信息是与对象自身定义的数据没有关系的额外存储成本,因此考虑到 JVM 的空间效率,Mark Word 被设计成了非固定的数据结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间,如 32bit JVM 除了上述所说的 Mark Word 默认存储结构外,还有如下可能变化的结构:
synchronized 同步锁一共有四种状态,无锁状态、偏向锁、轻量级锁、重量级锁,它会随着竞争情况逐渐升级。偏向锁和轻量级锁是 JDK1.6 对 synchronized 锁进行优化后新增加的,后面会提及。
每个对象都存在一个 Monitor 与之关联,Monitor 是每个 Java 对象天生自带的一个看不见的锁,叫做内部锁,是一种同步机制。在 HotSpot VM 中,Monitor 是由 objectMonitor.hpp 实现的,位于 HotSpot VM 源码中,是由 C++ 实现的。在 Mark Word 中,synchronized 重量级锁锁的标识位是 10,指针指向了 Monitor 对象的起始地址。
打开 objectMonitor.hpp 可以看到:
ObjectMonitor() {
_header = NULL;
_count = 0; //计数器
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL; //指向持有ObjectMonitor对象的线程
_WaitSet = NULL; //等待池
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //锁池
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
}
ObjectMonitor 有两个队列,等待池 _WaitSet 和锁池 _EntryList,用来保存 ObjectWaiter 的对象列表,每个对象锁的线程都会被封装成 ObjectWaiter 来保存到这两个队列中,_owner 是指向持有 ObjectMonitor 对象的线程。
当多个线程同时访问同一块代码时,首先会进入到 _EntryList 集合中。当线程获取到对象的 Monitor 后,就进入到 _object 区域,并把 Monitor 中的 _owner 变量设置为当前线程,同时 Monitor 中的计数器 _count 加一。若线程调用 wait() 方法,将释放当前持有的 Monitor,_owner 恢复成 NULL,_count 减一,同时该线程及 ObjectWaiter 实例就会进入到 _WaitSet 集合中,等待被唤醒。
Monitor 对象存在于每个 Java 对象的对象头中,synchronized 锁便是通过这种方式去获取锁的,这也是 Java 中任意对象可以作为锁的原因。
下面分析一下 synchronized 在字节码层面的具体语义实现。
看一下获取对象锁的代码:
package com.example.demo;
public class Account {
public void draw(double drawAmount) {
synchronized(this) {
System.out.println("hello");
}
}
public synchronized void drawTask(double drawAmount) {
System.out.println("hello");
}
}
通过 javac Account.java 命令把这段代码进行编译,得到 .class 文件的字节码,再通过 javap -verbose Account.class 命令打开字节码文件。
在字节码文件中找到 draw() 方法的字节码:
public void draw(double);
descriptor: (D)V
flags: ACC_PUBLIC
Code:
stack=2, locals=5, args_size=2
0: aload_0
1: dup
2: astore_3
3: monitorenter // monitorenter 指令指向了同步代码块的开始位置
4: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
7: ldc #3 // String hello
9: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
12: aload_3
13: monitorexit // monitorexit 指令指向了同步代码块的结束位置
14: goto 24
17: astore 4
19: aload_3
20: monitorexit // 异常结束时被执行的释放 monitor 的指令
21: aload 4
23: athrow
24: return
从字节码可知,同步代码块的实现使用的是 monitorenter 和 monitorexit,monitorenter 指令指向了同步代码块的开始位置,monitorexit 指向了同步代码块的结束位置。
当执行 monitorenter 指令时,当前线程将试图获取对象锁所对应 Monitor 的持有权,当对象锁的 Monitor 的进入计数器为 0,那线程可以成功取得 Monitor,并将计数器值加 1,取锁成功。
如果其他线程先于当前线程获取 Monitor 的持有权了,那么当前线程将阻塞在 monitorenter 位置,直到持有该锁的线程执行完毕及 monitorexit 指令被执行,执行线程将释放 Monitor 的锁,并将计数器值减 1 设置为 0,其他线程将开始竞争 Monitor 的持有权。
为了保证在方法异常时 monitorenter 和 monitorexit 仍然可以配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是为了执行 monitorexit 指令。
什么是重入?
从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入。
在字节码文件中找到 drawTask() 方法的字节码:
public synchronized void drawTask(double);
descriptor: (D)V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=3, args_size=2
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String hello
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
我们看到并没有 monitorenter 和 monitorexit,并且字节码也比较短。其实方法级的同步是隐式的,即无需通过字节码指令来控制。
在字节码文件中,有 ACC_SYNCHRONIZED 这么一个访问标志,用来区分该方法是不是同步方法。当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果被设置了,执行线程将会持有 Monitor,然后再执行方法,最后不管正常完成还是非正常完成,都释放 Monitor。
在方法执行期间,执行方法持有了 Monitor,其他任何线程都无法再获得同一个 Monitor,如果同步方法发生异常,持有的 Monitor 将会在异常抛到方法之外时自动释放。
以上便是同步代码块和同步方法实现的基本原理。
当线程释放锁时,JMM(Java 内存模型)会把该线程对应的本地内存中的共享变量刷新到主内存中;而当线程获取锁时,JMM 会把该线程对应的本地内存置为无效,从而使得被 Monitor 保护的临界区代码必须从主内存中读取共享变量。
Java 早期版本,synchronized 属于重量级锁,依赖于底层操作系统的 Mutex Lock 实现。而操作系统实现线程切换需要从用户态转换到核心态,时间开销比较大。
Java 1.6 及以后,HotSpot VM 对 synchronized 做了很多优化,减少重量级锁的使用,性能得到了很大的提升。优化点包括:
这些技术都是为了在线程之间更高效的共享数据以及解决竞争问题,从而提高程序性能。
互斥同步进入阻塞状态的开销都很大,应该尽量避免。大多数情况下,共享数据的锁定状态持续时间很短。自旋锁的思想是让一个线程在请求一个共享数据的锁时执行忙循环(自旋)一段时间,不让出 CPU,如果在这段时间内能获得锁,就可以避免进入阻塞状态。
但是,如果锁会被线程占用很长时间,那么进行忙循环操作占用 CPU 时间就会造成很大的性能开销,所以自旋锁只适用于共享数据的锁定状态很短的场景。
在 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。 | 线程阻塞,响应时间缓慢,在多线程下,频繁的获取释放锁,会带来巨大的性能消耗。 | 追求吞吐量,同步块或者同步方法执行时间较长的场景。 |
轻量级锁是由偏向锁升级而来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁竞争的时候,偏向锁就会升级为轻量级锁。
适合的场景:线程交替执行同步块的情况。
若存在同一时间访问同一锁的情况,就会导致轻量级锁升级为重量级锁。