synchronized

本文后面内容来自《深入理解java虚拟机》一文,这本文感觉就像jvm圣经一般,值得深入理解。

一、synchronized的特性

  • 原子性:原子是世界上的最小单位,具有不可分割性。比如 a=0;(a非long和double类型) 这个操作是不可分割的,那么我们说这个操作时原子操作。再比如:a++; 这个操作实际是a = a + 1,它不是一个原子操作,它是可分割的,它也有并发问题,即使你加上了volatile关键字,这一点跟volatile不同。
  • 可见性:是指线程之间的可见性,一个线程修改的状态对另一个线程是可见的。也就是一个线程修改的结果。另一个线程马上就能看到。
  • 有序性:即程序执行的顺序按照代码的先后顺序执行,为什么这么说了,因为jvm还会对输入代码进行乱序执行(out-of-order Execution)优化,处理器会在计算之后将乱序执行的结果重组,也就是java虚拟机的即时编译器中也有指令重排序优化。
  • 可重入性:synchronized和ReentrantLock都是可重入锁。当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁。可重入最大的作用是避免死锁,如:子类同步方法调用了父类同步方法,如没有可重入的特性,则会发生死锁;

二、synchronized的用法:

public class SynchronizedTest {

    public Object object = new Object();
    //成员函数加锁,需要获得当前类实例对象的锁
    public synchronized void add(){
        //TODO
    }
    // 静态方法加锁,需要获得当前类的锁
    public synchronized static void add1(){
        //TODO
    }

    public void method(){
        //需要获取SynchronizedTest类的锁
        synchronized (SynchronizedTest.class){
            //TODO
        }
        //需要获取object实例对象的锁
        synchronized (object){
            //TODO
        }
        //需要获取当前类实例对象的锁
        synchronized (this){
            //TODO
        }
    }
}

三、synchronized锁的实现

前面也写了synchronized有两种形式上锁,对方法上锁和代码块。他们都是在进入同步代码之前先获取锁,获取到锁之后锁的计数器+1,同步代码执行完锁的计数器-1,如果获取失败就阻塞式等待锁的释放。只是他们在同步块识别方式上有所不一样,从class字节码文件可以表现出来,一个是通过方法flags标志,一个是monitorenter和monitorexit指令操作。
查看反编译


synchronized同步代码块原理实现

需要注意的是有不止一个monitorexit呢?其实后面的monitorexit是来处理异常的,仔细看反编译的字节码,正常情况下第一个monitorexit之后会执行goto指令,也就是return语句,也就是说正常情况下只会执行第一个monitorexit释放锁,然后返回。而如果在执行中发生了异常,后面的monitorexit就起作用了,它是由编译器自动生成的,在发生异常时处理异常然后释放掉锁。

因为在synchronized同步方法我没找到flags里面多了一个ACC_SYNCHRONIZED标志,这标志用来告诉JVM这是一个同步方法,在进入该方法之前先获取相应的锁,锁的计数器加1,方法结束后计数器-1,如果获取失败就阻塞住,知道该锁被释放。所以这一块 先放着,后续再加上来。

下面这些部分来自《深入理解java虚拟机》

四、synchronized实现锁的基础

对象储存布局
  • 实例数据:存放类的属性数据信息,包括父类的属性信息;
  • 对齐填充:由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐;
  • 对象头:这部分拉出来单说。

对象头

对象头分为两部分:

  • Mark Word:用于储存对象自身的运行时数据,比如哈希码,GC分代年龄,这部分数据在32位和64位的虚拟机中分别为32bit和64bit,是实现轻量级锁和偏向锁的关键。
  • Class Metadata Address:这部分用于存储指向方法区对象数据类型的指针,如果是数组对象的话,还有一个额外的部分用于存储数组长度。

对象头信息是与对象自身定义的数据无关的额外存储成本,但是考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说,Mark Word会随着程序的运行发生变化,同时前面也说过在32bit和64bit上Mark Word储存结构也不太一样:

  • 32bit


    32bit无锁
32bit有锁.png

关于锁标志位这部分结合这张图来看:


Mark Word
  • 64bit


    64bit有锁和无锁

五、jvm对锁的优化

自旋锁和自适应性自旋锁

  • 自旋锁:出现背景是因为挂起线程和恢复贤臣改的操作需要转入内核态中完成,这明显不是一个好选择,所以可以让后面那个线程“稍等一下”,但是不放弃cpu时间,请注意,自旋锁是占用cpu时间的,只是减少了线程状态切换的消耗,如果说一直在那等肯定会极大浪费cpu性能,所以自旋次数试试10次,可以使用-XX:PreBlockSpin来更改。
  • 自适应性自旋锁:jdk1.6引入自适应性自旋锁,意味着自旋时间不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的转态来决定,例如线程如果自旋成功了,那么下次自旋的次数会增多,因为JVM认为既然上次成功了,那么这次自旋也很有可能成功,那么它会允许自旋的次数更多。反之,如果对于某个锁,自旋很少成功,那么在以后获取这个锁的时候,自旋的次数会变少甚至忽略,避免浪费处理器资源。有了自适应性自旋,随着程序运行和性能监控信息的不断完善,JVM对程序锁的状况预测就会变得越来越准确,JVM也就变得越来越聪明。

锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。
在《深入理解java虚拟机》一文中这样举得例子:

    public String concatString(String str1,String str2,String str3){
        return str1+str2+str3;
    }

String是一个不可变的类,在jdk1.5之前,这段代码会转为stringBuffer对象的连续append(),在jdk1.5之后,会转为StringBuilder对象的连续append()操作,因为stringBuffer.append()都有一个同步代码块,而这段代码很明显并不会出现并发问题,所以虽然这里有锁,但是在即时编译后会被安全消除掉。

锁粗化:

如果一些列的连续操作都是对同一个对象反复加锁和解锁,即时没有没有线程竞争,频繁的进行互斥同步操作也会导致不必要的性能损耗。

    public String concatString2(String str1,String str2,String str3){
        StringBuffer sb = new StringBuffer();
        sb.append(str1);
        sb.append(str2);
        sb.append(str3);
        return sb.toString();
    }

前面说过stringBuffer.append()都有一个同步代码块,这种情况下如果虚拟机检测到有这样一串零碎的操作都是对用一个对象加锁,将会把加锁同步的范围拓展(粗化)到整个操作序列的外部,也就是上面这段代码只加锁一次。

轻量级锁

加锁过程:
  • 代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为"01"状态,是否为偏向锁为"0"),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Recored)的空间,用于储存锁对象目前的Mark Word的拷贝(官方把这份拷贝加了个Displaced前缀,即Displaced Mark Word)。此时线程堆栈和对象头的状态如下:


    轻量级锁cas操作前堆栈和对象的状态
  • 将对象头的Mark Word拷贝到线程的锁记录(Lock Recored)中。

  • 虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。如果这个更新成功了,这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位将转变为"00",即表示此对象处于轻量级锁的状态。这时线程堆栈和对象头的状态如下:


    轻量级锁cas操作后堆栈和对象的状态
  • 如果更新失败,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,可以直接进入同步块继续执行,否则说明这个锁对象已经被其其它线程抢占了。如果多个线程竞争锁,进入自旋执行上一步,自旋结束后仍未获得锁,轻量级锁就需要膨胀为重量级锁,锁标志位状态值变为"10",Mark Word中储存就是指向重量级的指针,当前线程以及后面等待锁的线程也要进入阻塞状态。

释放锁的过程:
  • 使用CAS操作将对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来(依据Mark Word中锁记录指针是否还指向本线程的锁记录)。
  • 如果替换成功,整个同步过程就完成了,恢复到无锁的状态(01)。
  • 如果替换失败,说明有其他线程尝试获取该锁(此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的线程。

偏向锁

目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用CAS操作区消除同步使用的互斥量,那么偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不用做了。偏向锁默认是开启的,也可以关闭。
偏向锁"偏",就是"偏心"的"偏",它的意思是这个锁会偏向于第一个获得它的程序,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。

获取锁的过程:
  • 当锁第一次被线程尝试获取的时候,虚拟机会把对象头中的标志位设为"01",也就是偏向模式,同时使用cas操作吧获取到这个锁的线程id记录在对象的mark word中,如果cas设置成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时都不需要进行任何同步操作。
  • 当有另外一个线程尝试获取这个锁时,偏向模式就结束了,检查Mark Word是否为可偏向锁的状态,即是否偏向锁即为1即表示支持可偏向锁,否则为0表示不支持可偏向锁。如果是可偏向锁,则检查Mark Word储存的线程ID是否为当前线程ID,如果是则执行同步块,否则通过CAS操作去修改线程ID修改成本线程的ID,如果修改成功则执行同步代码块,否则挂起这个线程,升级为轻量级锁。
    也就是根据锁对象是否处于被锁定的状态,撤销偏向后到未锁定(标志位为01)或轻量级锁(标志位为00)的状态。
    偏向锁和轻量级锁的状态转化和对象Mark Word的关系如下:


    偏向锁和轻量级锁的状态转化和对象Mark Word
锁释放
  • 1:有其他线程来获取这个锁,偏向锁的释放采用了一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。
    -:2:等待全局安全点(在这个是时间点上没有字节码正在执行)。
    -:3:暂停拥有偏向锁的线程,检查持有偏向锁的线程是否活着,如果不处于活动状态,则将对象头设置为无锁状态,否则设置为被锁定状态。如果锁对象处于无锁状态,则恢复到无锁状态(01),以允许其他线程竞争,如果锁对象处于锁定状态,则挂起持有偏向锁的线程,并将对象头Mark Word的锁记录指针改成当前线程的锁记录,锁升级为轻量级锁状态(00)。

重量级锁

Synchronized是通过对象内部的一个叫做 监视器锁(Monitor)来实现的。但是监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为 “重量级锁”。

你可能感兴趣的:(synchronized)