前言
一直想写自己的博客,真正动手了才知道难啊。但没办法,博客总要写的,今天就从Java中最常用的synchronized开始吧!我想从三个方面写synchronized:
- 用法。
- 原理。
- 优化。
第一次写博客,可能会有一些错误,欢迎指出。
用法
synchronized的用法主要有三种:
- 使用普通同步方法中。
默认锁的是当前对象。即this。
用法如下:
public synchronized void fun(){
//代码...
}
- 使用在静态同步方法中。
静态方法不依赖于具体对象,而依赖于当前类的类对象,即锁的是当前类的类对象。具体原理部分分析。
用法如下:
public static synchronized void staticFun(){
//代码...
}
- 使用在同步代码块中。
这个就比较容易理解了,括号里的对象就是锁的对象。
用法如下:
public void fun(){
synchronized (object){
//代码...
}
}
原理
synchronized锁是通过对象头中的Mark Word部分的更新实现的,每个对象都通过某种联系关联着一个monitor对象,这个monitor对象中有很多属性来为synchronized提供服务,比如为wait()、notify()方法提供支持,重入的计数器等。因为一个对象中只关联着一个Mark Word和monitor对象,所以在同一时段最多只能有一个线程持有锁。
好了,下面开始深挖实现原理,可能会比较枯燥或感到无聊,如果只想了解概念的话看完下面的“什么是对象头”后可以直接跳到优化部分了。
什么是对象头
先给个定义:对象头是Java对象在内存中的一部分,对象头可以看成每个对象都有的一个属性。
在HotSpot虚拟机中,对象在内存中存储的区域可以分为三块区域:对象头、实例数据、对齐填充。
即一个对象可以看成下面这样:
而对象头又由下面三部分组成:
- Mark Word
- 指向类的指针
- 数组长度(数组专有)
对象头中与锁有关的就是这个Mark Word,Mark Word在不同的虚拟机中所占的空间不同,32位JVM中占32bit,64位JVM中占64bit。下面看一下32位JVM中的Mark Word是怎么样的:
这个在openjdk源码中能找得到,具体的就不贴上来了,对JVM源码有兴趣的可以看一下这篇文章:jdk源码剖析二: 对象内存布局、synchronized终极原理
实践
现在我们针对最常用的第三种用法分析一下,测试代码如下:
public class Main {
public void test(){
synchronized (this){
//code
}
}
}
现在我们去编译一下Main.java
javac Main.java
编译后在Main.java类的文件夹下会有相对应的Main.class文件,现在对其进行反编译
javap -c Main.class
进行反编译后,我们可以看到
分析
最顶上的几行是自动生成的默认构造函数,和synchronized没什么关系,直接从 public void test() 那行开始看。
aload_0、dup、astore_1都是一些存储、加载、管理操作数栈的指令,这里就不说了,有兴趣的可以去看一下大话+图说:Java字节码指令——只为让你懂这篇文章,里面这些指令更详细的描述。
现在主要看的是monitorenter和monitorexit两个指令,这两个指令的意思就是字面上的意思:monitor enter和monitor exit,进入和退出monitor?这个monitor是什么东西?
monitor详解
在JVM中有一个顶级基类:oopDesc。地位类似于Java中的Object,所有JVM中的类都继承自它。在oopDesc中主要有三个属性:
volatile markOop _mark;
union _metadata {
Klass* _klass;
narrowKlass _compressed_klass;
} _metadata;
oopDesc中还定义了一系列操作这三个属性的函数,就不贴出来了,现在主要看 _mark 属性, _mark 就是我们上面说的Mark Word,在JVM中使用markOopDesc实现,在markOopDesc中又有一个monitor()方法
ObjectMonitor* monitor() const {
assert(has_monitor(), "check");
// Use xor instead of &~ to provide one extra tag-bit check.
return (ObjectMonitor*) (value() ^ monitor_value);
}
这个方法返回一个ObjectMonitor指针,在HotSpot虚拟机中,就是使用这个ObjectMonitor实现monitor,先来看一下ObjectMonitor的属性:
ObjectMonitor() {
_header = NULL;
_count = 0;
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL;
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ;
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
}
这里面有很多属性,我们主要看其中四个 _owner 、 _recursions 、 _EntryList 和 _WaitSet 。这四个属性和我们平时的使用息息相关,下面分别介绍一下这四个属性。
_owner:指向当前获得锁的线程,线程执行与锁相关的操作都会检查一下这个属性。比如在进行执行object.wait(),JVM就会先检查一下与object关联的ObjectMonitor中_owner存放的是否为当前线程,如果不是,就会抛出一个IllegalMonitorStateException异常。
-
_recursions :这个属性为synchronized的可重入性提供支持,初始值为0,在进行synchronized加锁前,JVM会先检查加锁对象关联ObjectMonitor中_owner是否为NULL,有三种情况
如果为NULL,说明没有线程占用锁,此时_recursions为0,JVM会使用CAS操作将_owner更新为当前线程,并将此时_recursions设置为1。
如果为当前线程,说明当前线程占用了该锁,此时_recursions为大于0的整数,_recursions自增1.
如果不为NULL且不为当前线程,则说明不是当前线程占用该锁,则需要进行到一个队列中等待锁被释放,这个队列就是下面的_EntryList。
_EntryList:这个属性是一个存放线程的队列,里面存放着等待该锁释放的线程。
总所周知,在Java线程里有六种状态:
新建(NEW)
运行或可运行(RUNNABLE)
阻塞(BLOCKED)
等待(WAITING)
超时等待(TIMED_WAITING)
终止(TREMINATED)
当两个线程同时竞争同一个锁,竞争失败的线程状态会被置为BLOCKED并放入到被竞争锁的_EntryList队列。当然,如果锁已经被别的线程获取了,就直接进入到_EntryList了。持有该锁的线程释放锁后就会从这个队列和新加入竞争的线程竞争锁。
- _WaitSet:这个属性也是一个存放线程的队列,但存放的就不是等待该锁释放的线程了,而是在获得锁期间调用了wait()方法的线程,这时候线程的状态会被置为WAITING。没有线程调用对应的notify()或notifyAll()的话就永远都出不来了。
如果有线程调用对应的notify(),JVM就会根据调度算法从此队列中挑选一个线程(一般是队列头)加入竞争(因为锁大概率还被当前线程持有,一般也就是加入_EntryList当中)。
_owner、_EntryList、WaitSet三者的关系大致如下:
上面介绍的是重量级锁的加锁过程,解锁过程也或多或少提到一点,这里就不说了。大家也看到加锁解锁过程是多么繁琐,这些繁琐的过程虽然保证了加锁解锁的合理性,但却成为了高并发的瓶颈,这也是synchronized在优化之前被认为效率低的原因,被新出现的J.U.C包下的Lock虐的体无完肤,但亲生的毕竟是亲生的,JVM设计团队对synchronized进行了多次优化,使之效率高了起来。下面就说说这些优化。
优化
自旋锁和适应性自旋
前面提到,在锁竞争失败后会处于阻塞(BLOCKED)状态,Java线程与操作系统线程是一对一的,Java线程在RUNNING与BLOCKED的相互转换,对应操作系统线程也需要在用户态和内核态之间相互转换,用户态和内核态之间的切换对操作系统的并发性能带来很大压力,所以首先考虑能不能在这里优化。
经过大量的统计分析发现,很多线程对锁的占用时间只会持续很短的一段时间,为了这段时间去挂起和恢复线程根本不值得。如果物理机器有一个以上的处理器,能让两个线程并行执行,我们可以让后面请求锁的线程“稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否会很快释放锁。为了让线程等待,我们可以让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。
自旋并不是阻塞,如果挂起和恢复线程所需要的时间比锁被占用的时间更短,那自旋后可以直接获取锁,这效率自然比挂起恢复更高。但另一个问题是:如何确定是否应该自旋?
锁被占用的时间短,那自然效果很好;但如果锁被占用的时间过长,那自旋时间就会较长,在自旋期间可是会浪费处理器资源的,处理器在这段时间不能处理其它事情。所以自旋时间必须有一定的限度,如果在这个限度内没有获得锁,就只能乖乖挂起线程了。
后来又引入了自适应自旋的概念,自旋的时间不再固定,而是由一些统计数据决定,这样便能够最大限度地减少对系统资源的消耗。
锁消除
JVM检测到不可能被竞争的代码块中加锁,则会将不会对其加锁,这就是所谓的锁消除。
比如下面这段(来自《深入理解Java虚拟机》)
public String concatString(String s1, String s2, String s3){
return s1 + s2 + s3;
}
我们知道String是一个不可变的类,对字符串的链接操作总是生成新的String对象来进行,因此javac编译器会对String连接做自动优化。在JDK1.5之前,会转化为StringBuffer对象的连续append()操作,在JDK1.5及以后的版本中,会转化为StringBuilder对象的连续append()操作,上面的代码将会转化为下面这样子:
public String concatString(String s1, String s2, String s3){
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
众所周知,StirngBuffer是线程安全的,在对StringBuffer对象进行操作时都会对其上锁,锁对象就是操作的StringBuffer对象。在上面的代码中,sb对象只在concatString()方法中存在引用,外界永远不会得到sb对象的引用,也就是说其他线程无法访问它,所以这里的锁可以被安全地消除掉。
锁粗化
如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁的进行互斥同步操作也会导致不必要的损耗,这时就提出了锁粗化这个概念。
public 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()方法就属于这种情况,,如果虚拟机探测到有这样一串零碎的操作都是对同一个对象加锁,将会把加锁同步范围扩大(粗化)到整个操作序列外部。
偏向锁
前面原理部分讲的都是重量级锁的实现,操作是相当繁琐,有没有可能把过程简单一点?当然是有的,通过将加锁和解锁的过程的简化,JVM对synchronized进行了又一次优化:偏向锁和轻量级锁,现在先说一下偏向锁。
偏向锁,顾名思义,就是很偏心的锁,偏心谁?偏心第一个获得它的线程。第一个获得偏心锁的线程只需将对象头中的偏向锁标志位设置为1,并使用CAS操作将自己线程的id记录到对象头中的Mark Word中,再往后这个线程进入这个锁就只需要检验一下Mark Word中的线程id是否还是自己的就可以了,完事后直接走,都不用把线程id替换回来。
通过上面的描述大家可能会发现一个问题:第一个获得锁的线程锁用完后就拍拍屁股走人,后来想要获取锁的线程怎么办?这就涉及到偏向锁的撤销了,偏向锁是一种等到竞争出现才释放锁的机制,它的释放甚至比重量级锁的释放更麻烦,它需要等到一个没有正在执行的字节码的时间点(叫全局安全点),然后暂停拥有偏向锁的线程,检查持有偏向锁的线程是否还存活。如果线程不处于活动状态了,就将对象头设置为无锁状态;如果还活着,就让它先执行完,然后再将对象头标记为无锁状态,最后唤醒等待的线程。
从上面的分析看,偏向锁很适合在锁竞争不激烈、但不上锁又不能保证数据的安全的情况下使用。但在锁总是被很多不同的线程访问的时候,偏向锁就显得很多余了,甚至有时不使用偏向锁反而能提升性能。在得知偏向锁的优势与劣势之后,我们再来看看轻量级锁。
轻量级锁
轻量级锁的加锁解锁原理依旧是通过对象头的Mark Word进行。轻量级锁在加锁时先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到这个空间中。然后线程尝试使用CAS操作将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获取锁;如果失败,说明有其他线程竞争锁,当前线程便尝试使用上面介绍的自旋来获取锁。
轻量级锁相比偏向锁有一个解锁的过程,在同步代码块执行完之后线程要使用CAS操作将栈帧中存储的Mark Word替换回对象头。这个替换可能会失败,如果失败,就说明当前锁存在竞争,锁会膨胀为重量级锁。
这些特性也决定了轻量级锁的使用场景,同步代码块执行时间非常短时优势非常大,线程在竞争锁时不必挂起和唤醒,而是使用自旋来等待锁。
总结
synchronized主要有三种用法,锁的操作通过操作对象头来实现。在早期只有重量级锁,通过对并发规律进行总结,对锁进行了一些列的优化,其中便包括了偏向锁和轻量级锁,所以锁的种类主要有三种:偏向锁、轻量级锁、重量级锁。各锁的优缺点如下
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 | 如果线程之间存在竞争,会带来额外的所撤销的消耗 | 适用于只有一个线程访问的同步代码块 |
轻量级锁 | 竞争的线程不会阻塞,提高程序的响应速度 | 如果始终得不到锁竞争的线程,使用自旋会消耗CPU | 追求响应时间,同步代码块执行速度非常快 |
重量级锁 | 锁的竞争不使用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢 | 追求吞吐量,同步代码块执行时间较长 |
题外
我一直以为下面两种使用方法是一模一样的,JVM会自动优化使用在方法中的synchronized为在方法体内的synchronized。
public void synchronized fun(){
//code
}
public void fun(){
synchronized(this){
//code
}
}
后来不知道在哪看到不一样,于是去对第一段代码反编译了一下,发现并没有monitorenter和monitorexit
于是在反编译的时候又加上-v让其输入附加信息
javap -c -v Main.class
然后发现相比代码块中使用synchronized,在方法中申明synchronized多出了这么个东西:
于是去找了资料,发现这么一段话:
Method-level synchronization is performed implicitly, as part of method invocation and return. A synchronized method is distinguished in the run-time constant pool’s method_info structure by the ACC_SYNCHRONIZED flag, which is checked by the method invocation instructions. When invoking a method for which ACC_SYNCHRONIZED is set, the executing thread enters a monitor, invokes the method itself, and exits the monitor whether the method invocation completes normally or abruptly. During the time the executing thread owns the monitor, no other thread may enter it. If an exception is thrown during invocation of the synchronized method and the synchronized method does not handle the exception, the monitor for the method is automatically exited before the exception is rethrown out of the synchronized method.
大致意思如下:
同步方法是隐式的。一个同步方法会在运行时常量池中的method_info结构体中存放ACC_SYNCHRONIZED标识符。当一个线程访问方法时,会去检查是否存在ACC_SYNCHRONIZED标识,如果存在,则先要获得对应的monitor锁,然后执行方法。当方法执行结束(不管是正常return还是抛出异常)都会释放对应的monitor锁。如果此时有其他线程也想要访问这个方法时,会因得不到monitor锁而阻塞。当同步方法中抛出异常且方法内没有捕获,则在向外抛出时会先释放已获得的monitor锁
这只是一个题外,方法级的synchronized使用的仍然是monitor。