Synchronized
由于多线程环境下,多条线程可以共同操作一些共享数据,那么就有可能导致线程安全问题。synchronized关键字可以保证被它修饰的方法或代码块在任意时刻只能有一个线程被执行,从而解决了线程安全的问题。下面是本文涉及到关于synchronized的一些概括。
synchronized的使用方式
要注意的是synchronized锁的是对象而不是代码。即便synchronized修饰了方法,也是对该方法的实例对象或.class对象加锁。
还有就是,类锁和对象锁并不会互斥。比如线程A访问一个对象的synchronized普通方法,此时线程B也可以访问该对象的synchronized静态方法。
synchronized的底层实现原理
这里补充一下关于对象头的相关内容:
hotspot虚拟机中,一个对象在内存中的布局分为三块区域:对象头,实例数据,对齐填充。
对象头中存在一个Mark Word的数据结构,默认存储对象的hashCode,分代年龄,锁类型,锁标志位等信息。可以发现,Mark Word就是实现锁的关键,考虑到JVM的效率问题,Mark Word被设计为一个非固定的数据结构,以便于存储更多的有效信息。
也就是说,任何对象都存在着一把锁。而Mark Word中的重量级锁,也就是synchronized对象锁,其指针指向的是monitor对象的起始位置。
下面是hotspot虚拟机中monitor对象的实现源码,圈上的是比较关键的属性,这里先留个眼熟
就不继续深挖了,不然底层深不见底,我们回归正题。
第一张结构图中可以看到,synchronized修饰方法和代码块的实现方式是不同的,但归根结底都是属于JVM层面。
①synchronized代码块的情况
public class SynchronizedDemo {
public void method() {
synchronized (this) {
System.out.println("synchronized codes");
}
}
}
通过JDK自带的javap命令查看SynchronizedDemo.class的相关字节码信息(javap -verbose SynchronizedDemo.class)
可以发现,synchronized同步语句块的实现采用的是monitorenter和monitorexit指令,其中monitorenter指令指向同步块开始的位置,monitorexit指向同步代码块结束的位置。
从字节码中,我们可以看到在19处多了一个monitorenter指令,这是为了保证在方法在异常完成时,前面的monitorenter和monitorexit依然能够正确配对执行,编译器会自动产生一个能够处理所有异常的异常处理器,这里的monitorenter就是异常结束时释放monitor的指令。
获取锁的流程如下图所示:
当执行monitorenter指令,线程首先会先进入_EntryList集合(锁池)中试图获取monitor锁的持有权,若成功获取锁,则将锁的计数器_count属性设为1,并且将_owner设为当前获取monitor的线程。当某个成功获取到锁的线程调用了Object.wait()方法后,会释放当前锁,然后当前线程进入到_WaitSet集合(等待池)中等待被唤醒。若代码块执行完毕,也就是在执行monitorexit指令后,将锁的计数器设为0,表明锁被释放。
由此可以发现,monitor锁存在于每个Java对象的对象头中,synchronized就是通过这种方式获取对象的锁的,这也是为什么说synchronized锁的是对象。
②synchronized修饰方法的情况
public class SynchronizedDemo {
public synchronized void method(){
System.out.println("syn method");
}
}
synchronized修饰的方法并没有monitorenter和monitorexit指令,取而代之的是ACC_SYNCHRONIZED标识,该标识指明该方法是一个同步方法,JVM通过该标识来辨别一个方法是否为同步方法,从而执行相应的同步调度。
若一个同步方法在执行期间抛出了异常,并且在方法内部无法处理此异常,那么同步方法所持有的monitor将在异常抛到同步方法之外时自动释放。
synchronized的底层优化
在早期的Java版本中synchronized属于重量级锁,效率低下,因为对象的monitor锁是依赖于底层操作系统的互斥锁MutexLock实现的,Java的线程是映射操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的转换时需要从用户态转换为内核态,这个状态的转换需要相对较长的时间,这也是为什么早期synchronized效率低的原因。在Java6以后从JVM层面对synchronized做了较大的优化,如自旋锁,锁消除,锁粗化,轻量级锁等技术。
这里引入一个概念:上下文切换
多线程编程中,一般线程数都大于CPU核心数,而一个CPU核心在任意时刻只能被一个线程使用,为了让这些线程都能够得到有效的执行,CPU采取的策略是为每个线程分配时间片。当一个线程的时间片用完后就会重新回到非Runnable状态,让出CPU给其他线程使用,这个让出CPU的过程就属于一次上下文切换,上下文切换对系统来说意味着消耗大量的CPU时间。
自旋锁与自适应自旋锁
一个线程在尝试获取某个对象的锁失败后由就绪状态转为阻塞状态,进入该对象的锁池_EntryList中,也就是从用户态转换为内核态,此时会发生一次上下文切换,严重影响锁的性能,我们可以理解为阻塞状态就是影响锁性能的罪魁祸首。
但许多情况下,线程持有锁的时间较短,所以仅仅为了这一点时间就进行一次上下文切换会导致较大的性能开销,因此hotspot团队考虑:能不能让尝试获取锁的线程等一会儿不进入阻塞状态,看看持有锁的线程是否很快就释放锁。让线程执行忙循环等待锁的释放,但是又不会让出CPU,这个技术就是自旋。
但是若某个线程持有锁的时间过长,就会导致其他获取锁的线程一直处于循环等待的状态,若使用不当会造成CPU占用率极高。因此自旋锁的等待时间必须是有限的,如果自旋超过了限定次数仍然没有获得锁,就应该挂起线程。(自旋次数的默认值是10次,用户可以通过--XX:PreBlockSpin
来更改)
通常自旋次数的人为控制是很难的,因此JDK1.6 中引入了自适应自旋锁。自适应自旋锁带来的改进就是:自旋的时间不在固定了,而是由前一次锁上的自旋时间以及锁的拥有者的状态来决定。如果在一个锁对象上,尝试获取锁的线程通过自旋一段时间后成功获取到了锁,则JVM会认为该锁被自旋获取到的可能性很大,则会自动增加自旋的等待时间,反之,可能会直接取消掉尝试获取锁的线程的自旋过程,避免浪费CPU资源。
锁消除
指编译器在运行时,通过对运行上下文的扫描,如果检测到有共享数据不可能存在竞争,那么就执行锁消除。锁消除可以节省毫无意义的请求锁时间。
锁粗化
原则上,我们在编写代码的时候,最好将同步块的作用范围限制得尽量小,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待线程也可以尽快拿到锁。
通常情况下,以上原则都不存在问题,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,那么会带来很多不必要的性能开销。
如下面这段代码所示,run方法中while循环调用了synchronized方法,这样会使得对一个对象进行反复的加锁和解锁。
public class SynchronizedDemo implements Runnable{
private int tickets = 10;
@Override
public void run() {
while (tickets > 0) {
getTicket();
}
}
private synchronized void getTicket(){
System.out.println(tickets);
tickets--;
}
}
此时锁粗化就会将加锁范围扩大为如下:
@Override
public void run() {
synchronized(this){
while (tickets > 0) {
getTicket();
}
}
}
private void getTicket(){
System.out.println(tickets);
tickets--;
}
synchronized的四种状态
①无锁
这个没什么好说的
②偏向锁
在大多数情况下,锁不存在多线程竞争,总是由同一线程多次获得。因此为了减少同一个线程多次获取锁导致的性能开销,引入了偏向锁。我们回顾一下前文提到的Mark Word结构,之前有提到过,它是一个可变的数据结构,这就是实现锁的四种状态的根本原因。可以看到锁状态中,偏向锁部分包含一个线程ID,这个ID就是实现偏向锁的关键。
如果某个对象的锁被一个线程获取,那么该锁就进入偏向模式,此时Mark Word结构也变为偏向锁结构,它会记录当前线程的ThreadID,当线程执行完毕后该锁还未被其他线程获取,则同一个线程再次请求该锁时,则无需做任何同步操作。即获取锁的过程只需检查Mark Word的锁标志位为偏向锁及当前请求线程的ID=Mark Word中记录的ThreadID即可。
但偏向锁不适用与锁竞争激烈的场合,因此当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁。
轻量级锁
在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗,因为使用轻量级锁时,不需要申请互斥量。此外,轻量级锁的加锁和解锁都用到了CAS操作。
轻量级锁能够提升程序同步性能的依据是:对于绝大部分锁,在整个同步的周期内都是不存在竞争的,如果没有竞争,轻量级锁使用CAS操作避免了使用互斥操作的开销。但如果存在锁竞争,除了互斥开销,还会额外发生CAS操作,因此在有锁竞争的情况下,轻量级锁比传统的重量级锁更慢!如果锁竞争激烈,那么轻量级将很快膨胀为重量级锁!
重量级锁
就是比较常见的synchronized了。
synchronized和ReenTrantLock的区别
①二者都是可重入锁
可重入锁
当一个线程试图操作一个由其他线程持有的对象锁临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属性重入,比较晦涩,这里直接上代码:
synchronized (this){
System.out.println("hello");
synchronized (this){
System.out.println("world");
}
}
synchronized是可重入的,因此在某个线程获取到对象的锁之后,可以再次请求自己已经获取的对象的锁。这就意味者上述代码中,输出hello以后,可以把之前获取的monitor锁拿直接过来用,然后输出world。
②synchronized依赖于JVM而ReenTrantLock依赖于API
synchronized是依赖于JVM实现的,之前也提到过JDK1.6为synchronized关键字进行了很多优化,但都是在虚拟机层面实现的,并没有直接暴露给我们。
ReenTrantLock是JDK层面实现的,需要lock(),unlock()配合try,finally语句块来完成。我们可以通过源代码查看它的实现。
③ReeTrantLock比synchronized增加了一些高级功能
- ReenTrantLock提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()来实现,也就是说等待的线程可以通过该方法设置等待时间,若超时可选择放弃等待,改为处理其他事情。
- ReenTrantLock可以指定公平锁还是非公平锁,而synchronized只能是非公平锁。所谓公平锁也就是先等待的线程先获取锁,有点类似排队,默认情况下ReenTrantLock是非公平的,可以通过ReenTrantLock的构造方法指定是否为公平锁。但实际上,Java默认的调度策略很少会导致饥饿线程的调度发生,且公平锁会导致一定的吞吐量的下降,没有需求就尽量不用。
- synchronized关键字与wait(),notify/notifyAll()结合使用可以实现等待/通知的机制,ReenTrantLock需要借助Condition接口与newCondition()方法。Condition是JDK1.5之后才引入的,具有很好的灵活性,比如可以实现多路通知功能,也就是在一个Lock对象中可以创建多个Condition实例,线程对象可以注册在指定的Condition中,从而有选择性地进行线程通知,在调度上更加灵活。在使用notify/notifyAll方法进行通知时,被通知的线程由JVM选择,用ReenTrantLock类结合Condition则可以实现选择性通知。这个功能非常重要,而且是Condition接口默认提供的。而synchronized就相当于整个Lock对象中只有一个Condition实例,所有线程都注册在它一个身上,如果执行notifyAll方法就会通知所有处于等待状态的线程,这样就会造成很大的效率问题,而Condition的signalAll方法只会唤醒注册在该Condition实例中的所有等待线程。
④性能已经不是选择标准
JDK1.6之后,synchronized与ReenTrantLock的性能基本持平,而且虚拟机在未来的性能改进中会更偏向于原生的synchronized,所以还是提倡在synchronized能满足你的需求的情况下,优先考虑使用synchronized关键字来进行同步。优化后的synchronized和ReenTrantLock一样,在很多地方都是用到了CAS操作。
以上便是关于synchronized的一些记录(●'◡'●)