引言
在并发编程中,当多个线程访问同一个共享资源时,我们必须考虑如何维护数据的原子性。Java 是通过 synchronized
关键字实现锁功能来做到这点的,synchronized是 JVM 实现的一种内置锁,锁的获取和释放由 JVM 隐式实现。
锁的本质
如上图所示,多个线程要访问同一个资源。线程就是一段运行的代码,资源就是一个变量、对象、文件等;而锁就是要实现线程对资源访问的控制,保证同一时刻只能有一个线程去访问某一个资源。
从程序的角度看,锁其实就是一个对象,那么这个对象需要完成以下几个事情:
- 对象内部有一个标志位,记录自己是否被某个线程占用;
- 如果这个对象被某个线程占用,得记录这个线程的
Thread ID
; - 这个对象需要维护一个
thread id list
,记录其他所有阻塞的、等待获取这个锁的线程,在当前线程释放锁后,从这个 thread id list里面取出一个线程唤醒;
基于上述的描述,来学习一下 synchronized 的使用及原理
基本使用
synchronized 关键字可以作用于方法或方法内的局部代码块。
作用于方法
public synchronized void method1() { // code }
作用于局部代码块
public void method2() {
Object o = new Object();
synchronized (o) {
//code
}
}
假设现在有一个Counter
类,如下
public class Counter {
private int increasedSum = 0;
private int decreasedSum = 0;
public void add(int value) {
increasedSum += value;
}
public void substract(int value) {
decreasedSum -= value;
}
}
尽管add
函数和substract
函数是线程不安全的,由于它们访问的共享资源不同,所以它们是可以并发执行的。
我们应该如何使用synchronized
加锁,既保证类为线程安全的,又保证两个函数可以并发执行呢?
public class Counter {
private int increasedSum = 0;
private int decreasedSum = 0;
private Object obj1 = new Object();
private Object obj2 = new Object();
public void add(int value) {
synchronized (obj1) {
increasedSum += value;
}
}
public void substract(int value) {
synchronized (obj2) {
decreasedSum -= value;
}
}
}
synchronized
关键字底层使用的锁是Monitor
锁,每个对象实例都有一个 Monitor
锁,Monitor
锁是寄生于对象存在的,Monitor
可以和对象一起创建、销毁。
如果我们想使用一个新的 Monitor
锁,只需新创建一个对象即可。
所以为了让add
函数和substract
函数之间能并发执行,可以对这两个函数加不同的锁,即分别使用 obj1
上的锁和 obj2
上的锁。
对象锁和类锁
synchronized 修饰普通方法时,锁为当前实例对象;修饰代码块时,锁为括号里的对象,这些都是对象锁。
与对象锁相对应的是类锁。
public synchronized static void method3() {
// code
}
当用 synchronized
修饰静态方法时,会隐式的使用当前类的类锁;
对于类锁而言,synchronized
使用的也是某个对象上的 Monitor
锁,而这个对象比较特殊,是类的 Class
类对象。
Class
类是所有类的抽象,每个类在 JVM 中都有一个 Class
类对象来表示这个类。
锁的字节码
来看下 synchronized
对应的字节码长什么样子。
public class SynTest {
//关键字在实例方法上,锁为当前实例
public synchronized void method1() {
// code
}
//关键字在代码块上,锁为括号里面的对象
public void method2() {
Object o = new Object();
synchronized (o) {
// code
}
}
}
通过反编译看下具体字节码的实现,运行以下反编译命令,就可以输出我们想要的字节码:
javac -encoding UTF-8 SynTest.java //先运行编译class文件命令
javap -v SynTest.class //再通过javap打印出字节文件
method1
对应的字节码如下,从字节码中,我们发现,实际上,编译器只不过是在函数的flags
中添加了ACC_SYNCHRONIZED
标记而已;
JVM 使用ACC_SYNCHRONIZED
标记来区分同步方法,当方法调用时,调用指令先检查该方法是否有ACC_SYNCHRONIZED
标志,如果设置了该标志,执行线程将先持有 Monitor
对象,然后再执行方法。在该方法运行期间,其它线程将无法获取到该 Mointor
对象,当方法执行完成后,再释放该 Monitor
对象。
public synchronized void method1();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=0, locals=1, args_size=1
0: return
LineNumberTable:
line 13: 0
method2
对应字节码如下,会发现:synchronized
在修饰同步代码块时,是由 monitorenter
和 monitorexit
指令来实现同步的。进入 monitorenter
指令后,线程将持有 Monitor
对象,退出 monitorenter
指令后,线程将释放该 Monitor
对象。
public void method2();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=4, args_size=1
0: new #2 // class java/lang/Object
3: dup
4: invokespecial #1 // Method java/lang/Object."<init>":()V
7: astore_1
8: aload_1
9: dup
10: astore_2
11: monitorenter
12: aload_2
13: monitorexit
14: goto 22
17: astore_3
18: aload_2
19: monitorexit
20: aload_3
21: athrow
22: return
从上述实例可以看出,synchronized
语句编译为字节码,只是做了一个简单的翻译而已。我们无法通过synchronized
对应的字节码了解其底层实现原理,需要继续深挖。
底层实现原理
在Hotspot JVM中,Monitor
锁对应的实现类为ObjectMonitor
类,而 ObjectMonitor
是由 C++ 的 ObjectMonitor.hpp
文件实现,如下所示:
ObjectMonitor() {
_header = NULL;
_count = 0; //记录个数
_waiters = 0,
_recursions = 0;
_object = NULL; //该 Monitor 锁所属的对象
_owner = NULL; //获取到该 Monitor 锁的线程
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ; //没有获取到锁的线程暂时加入_cxq
FreeNext = NULL ;
_EntryList = NULL ; //存储等待被唤醒的线程
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
通过_object
成员变量,可以得到 Monitor
锁所属的对象,也可以通过对象查找到对应的 Monitor
锁,对象头中的 Mark Word
字段用来记录对象所对应的 Monitor
锁。
Monitor 锁是如何实现加锁、解锁的呢?
竞争锁
多个线程同时请求获取 Monitor
锁时,它们会通过 CAS 来设置ObjectMonitor
的_owner
字段,谁设置成功,谁就获取到了这个 Monitor
锁。
排队等待锁
成功获取到锁的线程去执行代码,没有获取到锁的线程会被放入ObjectMonitor
的_cxq
中等待锁,_cxq
是一个单链表,链表节点的定义如下ObjectWaiter
类所示。ObjectWaiter
类中包含线程的基本信息以及其他一些结构信息,比如_prev
指针、_next
指针。
class ObjectWaiter : public StackObj {
public:
enum TStates { TS_UNDEF, TS_READY, TS_RUN, TS_WAIT, TS_ENTER, TS_CXQ } ;
enum Sorted { PREPEND, APPEND, SORTED } ;
ObjectWaiter * volatile _next;
ObjectWaiter * volatile _prev;
Thread* _thread;
jlong _notifier_tid;
ParkEvent * _event;
volatile int _notified ;
volatile TStates TState ;
Sorted _Sorted ; // List placement disposition
bool _active ; // Contention monitoring is enabled
public:
ObjectWaiter(Thread* thread);
void wait_reenter_begin(ObjectMonitor *mon);
void wait_reenter_end(ObjectMonitor *mon);
};
ObjectWaiter不仅用来表示单链表的节点(_cxq
),还用来表示双向链表的节点(_EntryList
和_WaitSet
),当用来表示单链表的节点时,ObjectWaiter中的_prev
指针设置为null。
通知排队等待锁的线程去竞争锁
当持有锁的线程释放锁后,它会从_EntryList
中取出一个线程,被取出的线程会再次通过 CAS操作去竞争 Monitor 锁;
如果_EntryList
中没有线程,就会先将_cxq
中的线程搬移到_EntryList
中去,然后再从_EntryList
中取出线程。
这里面有几个问题,需要说明一下
1、为什么从_EntryList
中取出的线程不直接获取锁而是通过CAS操作去竞争锁?
因为此时有可能存在新来的线程(非_EntryList里的线程)也在竞争锁。
2、为什么不直接从_cxq
取线程,而是要将_cxq
中的线程倒腾到_EntryList
中再取呢?
目的是减少多线程环境下链表存取的冲突,_cxq只负责存操作(往链表中添加节点),_EntryList只负责取操作(从链表中删除节点),冲突减少,线程安全性处理就变得简单。
因为多个线程有可能同时竞争锁失败,同时存入_cxq中,所以,我们需要通过CAS操作来保证往_cxq中添加节点操作的线程安全性。
因为只有释放锁的线程才会从_EntryList中取线程,所以,_EntryList的删除节点操作是单线程操作,不存在线程安全问题。
阻塞
没有获取锁的线程会阻塞,并且对应的内核线程不再分配时间片。
Java线程采用1:1线程模型来实现,一个Java线程会对应一个内核线程。应用程序提交给Java线程执行的代码,会一股脑地交给对应的内核线程来执行。内核线程在执行的过程中,如果遇到synchronized
关键字,会执行上述的步骤。
如果没有竞争到锁,则内核线程会调用park()
函数将自己阻塞,这样CPU就不再分配时间片给它。
取消阻塞
持有锁的线程在释放锁之后,从_EntryList
中取出一个线程时,就会调用unpark()
函数,取消对应内核线程的阻塞状态,恢复分配时间片,这样才能让它去执行竞争锁的代码。
流程图
总结
JVM 在 JDK1.6 中引入了分级锁机制来优化 synchronized
,当一个线程获取锁时,首先对象锁将成为一个偏向锁,这样做是为了优化同一线程重复获取导致的用户态与内核态的切换问题;其次如果有多个线程竞争锁资源,锁将会升级为轻量级锁,它适用于在短时间内持有锁,且分锁有交替切换的场景;轻量级锁还使用了自旋锁来避免线程用户态与内核态的频繁切换,大大地提高了系统性能;但如果锁竞争太激烈了,那么同步锁将会升级为重量级锁。
关于锁升级的文章,后续将再研究研究…
参考文献
《Java 编程之美》
《Java 性能调优实战》
《Java 并发编程实战》