在Java
并发编程中synchronized
一直是元老级角色,很多人都会称呼它为重量级锁。但是,随着Java SE 1.6对synchronized
进行了各种优化之后,有些情况下它就并不那么重了。下面来一起探索synchronized
的特性、基本使用、底层实现以及JVM如何对synchronized进行优化。
原子性
原子性就是指一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。被synchronized
修饰的类或对象的所有操作都是原子的,因为在执行操作之前必须先获得类或对象的锁,直到执行完才能释放,这中间的过程无法被中断(除了已经废弃的stop()方法),即保证了原子性。
可见性
可见性是指多个线程访问一个资源时,该资源的状态、值信息等对于其他线程都是可见的。synchronized
对一个类或对象加锁时,一个线程如果要访问该类或对象必须先获得它的锁,而这个锁的状态对于其他任何线程都是可见的,并且在释放锁之前会将对变量的修改刷新到主存当中,保证资源变量的可见性,如果某个线程占用了该锁,其他线程就必须在锁池中等待锁的释放。
有序性
有序性指程序执行的顺序按照代码先后执行。synchronized
保证了每个时刻都只有一个线程访问同步代码块,也就确定了线程执行同步代码块是分先后顺序的,保证了有序性。
可重入性
synchronized
是可重入锁。当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁。通俗一点讲就是说一个线程拥有了锁仍然还可以重复申请锁。
synchronized可以修饰成员方法、静态方法和代码块。
public synchronized void method1(){
//do something...
}
任何线程必须获取该类的实例对象的锁,才能访问该方法。
public static synchronized void method2(){
//do something...
}
任何线程必须获取该类的锁,才能访问该方法。
public void method(){
synchronized (this){
//do something...
}
synchronized (Test.class){
//do something...
}
}
修饰代码块分为两种
从语法上讲,Synchronized可以把任何一个非null对象作为"锁",在HotSpot JVM
实现中,锁有个专门的名字:对象监视器(Object Monitor)。
总结起来synchronized
有两种形式上锁,一个是对方法上锁,一个是构造同步代码块。他们的底层实现其实都一样,都是获取对象锁(类也是Class对象),在进入同步代码之前先获取锁,获取到锁之后锁的计数器+1,同步代码执行完锁的计数器-1,如果获取失败就阻塞式等待锁的释放。我们从字节码的角度分析一下这两种形式下的上锁方式。
public class Test {
private static int i = 0;
public void method(){
synchronized (this){
i++;
}
}
}
查看对应的字节码
这里有两个指令
monitor
)。当monitor
被占用时就会处于锁定状态,线程执行monitorenter
指令时尝试获取monitor
的所有权,过程如下
monitor
,然后将进入数设置为1,该线程即为monitor
的所有者;monitor
的进入数加1;monitor
的进入数为0,再重新尝试获取monitor
的所有权。monitorexit
的线程必须是objectref
所对应的monitor
的所有者。指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。了解完monitorenter
和monitorexit
和两个指令,我们总结一下synchronized
同步代码块的原理:
monitorenter
指令插入到同步代码块的开始位置,monitorexit
指令插入到同步代码块的结束位置,JVM需要保证每一个monitorenter
都有一个monitorexit
与之相对应。任何对象都有一个monitor
与之相关联,线程执行到monitorenter
指令时,将会尝试获取对象所对应的monitor
所有权,即尝试获取对象的锁。当且一个monitor
被持有之后,他将处于锁定状态。monitorexit
指令,这是因为第1次为执行正常退出释放锁,第2次为发生异常退出释放锁。正常情况下第一个monitorexit
之后会执行goto
指令,而该指令转向的就是22行的return
,也就是说正常情况下只会执行第一个monitorexit
释放锁,然后返回。而如果在执行中发生了异常,第二个monitorexit
就起作用了,它是由编译器自动生成的,在发生异常时处理异常然后释放掉锁。public class Test {
private static int i = 0;
public synchronized void method() {
i++;
}
}
查看对应的字节码
根据上图的字节码,同步方法并没有通过指令monitorenter
和monitorexit
来完成(理论上其实也可以通过这两条指令来实现),只是其常量池中多了ACC_SYNCHRONIZED标示符。JVM就是根据该标示符来实现方法的同步的。方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor
对象。同步方法的实现是一种隐式的方式,无需通过字节码来完成。
在讲解synchronized底层原理前,我们先来了解一下Java对象头和Monitor。
在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充
- 对象头:Java对象头一般占有2个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit,在64位虚拟机中,1个机器码是8个字节,也就是64bit),但是 如果对象是数组类型,则需要3个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。
- 实例数据:存放类的属性数据信息,包括父类的属性信息;
- 对齐填充:由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐;
synchronized
用的锁是存在Java
对象头里的,Hotspot
虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Class Pointer(类型指针)。
Class Pointer
:是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例Mark Word
:用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键。Mark Word用于存储对象自身的运行时数据,如:哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。下面是无锁状态下Mark Word部分的存储结构(32位虚拟机)
锁状态 | 25bit | 4bit | 1bit(是否是偏向锁) | 2bit(锁标志位) |
---|---|---|---|---|
无锁状态 | 对象的hashCode |
对象分代年龄 | 0 | 01 |
对象头信息是与对象自身定义的数据无关的额外存储成本,但是考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间。例如,在32位的HotSpot虚拟机中对象未被锁定的状态下,Mark Word
的32bit空间中的25位用于存储对象的hashCode,4bit用于存储对象分代年龄,2bit用于存储锁标志位,1bit固定为0,其他情况如下表所示
存储内容 | 标志位 | 状态 |
---|---|---|
对象哈希码、对象分代年龄 | 01 | 未锁定 |
指向锁记录的指针 | 00 | 轻量级锁定 |
指向重量级锁的指针 | 10 | 膨胀(重量级锁定) |
空,不需要记录信息 | 11 | GC标记 |
偏向线程ID、偏向时间戳、对象分代年龄 | 01 | 可偏向 |
所以,Mark Word会随着程序的运行发生变化,可能变化为存储以下4种数据:
在64位虚拟机下,Mark Word是64bit大小的,其存储结构如下:
对象头的最后两位存储了锁的标志位,01是初始状态,未加锁,其对象头里存储的是对象本身的哈希码,随着锁级别的不同,对象头里会存储不同的内容。偏向锁存储的是当前占用此对象的线程ID;而轻量级则存储指向线程栈中锁记录的指针。从这里我们可以看到,“锁”这个东西,可能是个锁记录+对象头里的引用指针(判断线程是否拥有锁时将线程的锁记录地址和对象头里的指针地址比较),也可能是对象头里的线程ID(判断线程是否拥有锁时将线程的ID和对象头里存储的线程ID比较)。
Monitor可以理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。每一个锁都对应一个monitor
对象,在HotSpot
虚拟机中它是由ObjectMonitor
实现的(C++实现)。每个对象都存在着一个monitor
与之关联,对象与其monitor
之间的关系有存在多种实现方式,如monitor
可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个monitor
被某个线程持有后,它便处于锁定状态。ObjectMonitor
的数据结构如下
ObjectMonitor() {
_header = NULL;
_count = 0; //记录个数
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
ObjectMonitor
中有两个队列,WaitSet
和 EntryList
,用来保存ObjectWaiter
对象列表( 每个等待锁的线程都会被封装成ObjectWaiter
对象),_owner
指向持有ObjectMonitor
对象的线程,当多个线程同时访问一段同步代码时,首先会进入 _EntryList
集合,当线程获取到对象的monitor
后进入 _Owner
区域并把monitor
中的owner
变量设置为当前线程,同时monitor
中的计数器count
加1,若线程调用 wait()
方法,将释放当前持有的monitor
,owner
变量恢复为null
,count
自减1,同时该线程进入 WaitSet
集合中等待被唤醒。若当前线程执行完毕也将释放monitor
(锁)并复位变量的值,以便其他线程进入获取monitor
(锁)。如下图所示
由此看来,monitor
对象存在于每个Java
对象的对象头中(存储的指针的指向),synchronized
锁便是通过这种方式获取锁的,也是为什么Java
中任意对象可以作为锁的原因,同时也是notify/notifyAll/wait
等方法存在于顶级对象Object
中的原因。
熟悉了Java对象头和Monitor的概念,我们来进一步分析synchronized
在字节码层面的具体语义实现
根据上面的字节码,我们知道同步语句块的实现使用的是monitorenter
和 monitorexit
指令,其中monitorenter
指令指向同步代码块的开始位置,monitorexit
指令则指明同步代码块的结束位置,当执行monitorenter
指令时,当前线程将试图获取 objectref
(即对象锁) 所对应的 monitor
的持有权,当 objectref
的 monitor 的进入计数器为 0,那线程可以成功取得 monitor,并将计数器值设置为 1,取锁成功。如果当前线程已经拥有 objectref
的 monitor 的持有权,那它可以重入这个 monitor
,重入时计数器的值也会加 1。倘若其他线程已经拥有 objectref
的 monitor 的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即monitorexit
指令被执行,执行线程将释放 monitor
(锁)并设置计数器值为0 ,其他线程将有机会持有 monitor 。值得注意的是编译器将会确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter
指令都有执行其对应 monitorexit
指令,而无论这个方法是正常结束还是异常结束。为了保证在方法异常完成时 monitorenter
和 monitorexit
指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit
指令。从字节码中也可以看出多了一个monitorexit
指令,它就是异常结束时被执行的释放monitor 的指令。
从字节码看出,synchronized
修饰的方法并没有monitorenter
指令和monitorexit
指令,取得代之的是ACC_SYNCHRONIZED
标识。即方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM可以从方法常量池中的方法表结构(method_info Structure
) 中的 ACC_SYNCHRONIZED
访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED
访问标志是否被设置,如果设置了,执行线程将先持有monitor
, 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor
。在方法执行期间,执行线程持有了monitor
,其他任何线程都无法再获得同一个monitor
。如果一个同步方法执行期间抛 出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor
将在异常抛到同步方法之外时自动释放。
Java早期版本中,synchronized
属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,而操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此早期的synchronized效率较低。不过在Java 6之后Java官方对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
偏向锁是Java 6之后加入的新锁,它是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。偏向锁在Java6及更高版本中是默认启用的, 但是它在程序启动几秒钟后才激活. 可以使用-XX:BiasedLockingStartupDelay=0
来关闭偏向锁的启动延迟, 也可以使用-XX:-UseBiasedLocking=false
来关闭偏向锁, 那么程序会直接进入轻量级锁状态。
偏向锁的释放采用了一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。偏向锁的撤销需要等待全局安全点(这个时间点是上没有正在执行的代码)。首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word
要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。
引入轻量级锁的主要目的是在多没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁。对于轻量级锁,其性能提升的依据是“对于绝大部分的锁,在整个生命周期内都是不会存在竞争的”,如果打破这个依据则除了互斥的开销外,还有额外的CAS操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢。
hashcode、0、01
),若是,则JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record
)的空间,用于存储锁对象目前的Mark Word
的拷贝(官方把这份拷贝加了一个Displaced
前缀,即Displaced Mark Word
);否则执行步骤3;总结一下加锁解锁过程:
Mark Word
复制到自己的锁记录中, 两者竞争去获取锁, 假设线程A成功获取锁, 并将对象c的对象头中的线程ID(Mark Word
中)修改为指向自己的锁记录的指针, 这时线程B仍旧通过CAS去获取对象c的锁, 因为对象c的MarkWord中的内容已经被线程A改了, 所以获取失败. 此时为了提高获取锁的效率, 线程B会循环去获取锁, 这个循环是有次数限制的, 如果在循环结束之前CAS操作成功, 那么线程B就获取到锁, 如果循环结束依然获取不到锁, 则获取锁失败, 对象c的Mark Word
中的记录会被修改为重量级锁, 然后线程B就会被挂起, 之后有线程C来获取锁时, 看到对象c的Mark Word
中的是重量级锁的指针, 说明竞争激烈, 直接挂起。MarkWord
改回自己栈中复制的那个Mark Word
, 因为对象c中的Mark Word
已经被指向为重量级锁了, 所以CAS失败. 线程A会释放锁并唤起等待的线程, 进行新一轮的竞争。偏向锁、轻量级锁和重量级锁对比
锁类型 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗, 和执行非同步代码方法的性能相差无几. | 如果线程间存在锁竞争, 会带来额外的锁撤销的消耗 | 适用于只有一个线程访问的同步场景 |
轻量级锁 | 竞争的线程不会阻塞, 提高了程序的响应速度 | 如果始终得不到锁竞争的线程, 使用自旋会消耗CPU | 追求响应时间, 同步快执行速度非常快 |
重量级锁 | 线程竞争不适用自旋, 不会消耗CPU | 线程堵塞, 响应时间缓慢 | 追求吞吐量, 同步快执行时间速度较长 |
线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。所以引入自旋锁。所谓自旋锁,就是让该线程等待一段时间,不会被立即挂起,看持有锁的线程是否会很快释放锁。这里的等待就是执行一空循环(自旋)。虽然它可以避免线程切换带来的开销,但是它占用了处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好,反之,自旋的线程就会白白消耗掉处理的资源,它不会做任何有意义的工作,这样反而会带来性能上的浪费。因此,自旋等待的时间(自旋的次数)必须要有一个限度,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起。 自旋锁在JDK 1.4.2中引入,默认关闭,但是可以使用-XX:+UseSpinning
开开启,在JDK1.6中默认开启。同时自旋的默认次数为10次,可以通过参数-XX:PreBlockSpin
来调整。
JDK 1.6引入了更加聪明的自旋锁,即自适应自旋锁。所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。某个线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。 有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测会越来越准确,虚拟机会变得越来越聪明。
为了保证数据的完整性,我们在进行操作时需要对这部分操作进行同步控制,但是在有些情况下,JVM检测到不可能存在共享数据竞争,这是JVM会对这些同步锁进行锁消除。锁消除的依据是逃逸分析的数据支持。 如果不存在竞争,就不需要加锁,所以锁消除可以节省毫无意义的请求锁的时间。比如我们在使用一些JDK的内置API时,如StringBuffer
、Vector
、HashTable
等,这个时候会存在隐形的加锁操作。比如StringBuffer
的append()
方法,Vector
的add()
方法。
public void vectorTest(){
Vector<String> vector = new Vector<String>();
for(int i = 0 ; i < 10 ; i++){
vector.add(i + "");
}
System.out.println(vector);
}
在运行这段代码时,JVM可以明显检测到变量vector
没有逃逸出方法vectorTest()
之外,所以JVM可以大胆地将vector
内部的加锁操作消除。
通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽可能短,但是大某些情况下,一个程序对同一个锁不间断、高频地请求、同步与释放,会消耗掉一定的系统资源,因为锁的讲求、同步与释放本身会带来性能损耗,这样高频的锁请求就反而不利于系统性能的优化了,虽然单次同步操作的时间可能很短。锁粗化就是告诉我们任何事情都有个度,有些情况下我们反而希望把很多次锁的请求合并成一个请求,以降低短时间内大量锁请求、同步、释放带来的性能损耗。
如下代码,每次循环都会进行锁的请求、同步与释放,因此我们可以把加锁代码写在循环体的外面,这样一次锁的请求就可以达到我们的要求(除非循环需要花很长时间,但其它线程等不起,要给它们执行的机会)。
//优化前
for(int i=0;i<size;i++){
synchronized(lock){
//do something...
}
}
//优化后
synchronized(lock){
for(int i=0;i<size;i++){
//do something...
}
}