Synchronized 的原理

Synchronized

在早期java版本中,Synchronized是重量级锁,效率低下,是因为监视器锁(monitor)是通过底层计算机操作系统的Mutex Lock 来实现锁.java的线程是映射到操作系统的原生线程上.如果要挂起或者唤醒一个线程,都需要操作系统内核帮忙完成. 而操作系统实现线程之间的切换是需要从用户态转换到内核态 (https://blog.csdn.net/shanghx_123/article/details/83151064),这个状态的转换需要相对较长的时间,时间成本比较高,因此Synchronized的效率比较低下.因此在1.6以后实现了大量的优化. 比如自旋锁,自适应自旋锁,偏向锁,轻量级锁,中间级锁(这个其实就是早期Synchronized的使用方式).

java中每个对象都有对应的monitor对象,通过该monitor对象来保证锁的进入和退出.但是对于修饰的方法,是通过在方法上表示ACC_SYNCHRONIZED标识,标注该方法是同步方法.

Synchronized关键字的三种使用方式:
Synchronized可以用来修饰 静态方法, 非静态方法, 修饰代码块

  1. 修饰非静态方法时,Synchronized锁作用于当前实例对象,进入同步代码块的时候要获取实例对象的锁.
  2. 修饰静态方法时,静态方法在类对象中只会创建一次的方法,因此需要访问该静态方法时,就需要获取类对象的锁,由此进入修饰的同步代码块.在同时访问一个被Synchronized修饰的非静态实例方法时,同时访问被Synchronized修饰的静态方法的时,是可以同步访问的,因为获取的锁对象是不一样的. 被Synchronized修饰的非静态方法获取的是类的实例的锁对象.而被Synchronized修饰的静态方法获取的是类对象的锁对象.
  3. 修饰代码块.(Synchronized(对象))这种方式获取的是被修饰的对象的锁对象 .

下边来看看java中Synchronized的方法:

public class SynchronizedDemo {

    public void testSyncDemo(){
        synchronized (this){
            System.out.println("hello lock");
        }
    }

    public synchronized void testMethod(){
        System.out.println("hello method");
    }
    public static synchronized void testMethodStatic(){
        System.out.println("hello method static");
    }
}

通过反编译类,看一下在底层是怎么实现的.

  1. 找到对应的类的目录.
  2. javac SynchronizedDemo.class
  3. javap -c -v SynchronizedDemo
 public chenyi.demo.SynchronizedDemo();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 3: 0

  public void testSyncDemo();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter
         4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         7: ldc           #3                  // String hello lock
         9: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        12: aload_1
        13: monitorexit
        14: goto          22
        17: astore_2
        18: aload_1
        19: monitorexit
        20: aload_2
        21: athrow
        22: return
      Exception table:
         from    to  target type
             4    14    17   any
            17    20    17   any
      LineNumberTable:
        line 6: 0
        line 7: 4
        line 8: 12
        line 9: 22
      StackMapTable: number_of_entries = 2
        frame_type = 255 /* full_frame */
          offset_delta = 17
          locals = [ class chenyi/demo/SynchronizedDemo, class java/lang/Object ]
          stack = [ class java/lang/Throwable ]
        frame_type = 250 /* chop */
          offset_delta = 4

  public synchronized void testMethod();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #5                  // String hello method
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 12: 0
        line 13: 8

  public static synchronized void testMethodStatic();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
    Code:
      stack=2, locals=0, args_size=0
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #6                  // String hello method static
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 15: 0
        line 16: 8
}

在 testSyncDemo 中是通过 monitorenter 在进入同步代码块时,获取锁.通过monitorexit 在结束同步代码块时释放锁. 可以看到有两个monitorexit,是保证在方法执行异常时,可以释放锁
在Synchronized修饰的方法上,则是没有monitor的,此时是通过ACC_SYNCHRONIZED来标识此方法是一个同步代码块. 在正常的进入方法和退出方法时,都是需要获取该对象的锁.

接下来讲讲在1.6以后引进的对于Synchronized优化的锁.

自旋锁

在使用被Synchronized修饰的方法或者代码块中,大部分的执行时间就很快,考虑到Synchronized锁在使用过程中用户态到内核态的切换,就非常没有必要了. 因此隆重推出自旋锁. 自旋锁本质是CAS(compare and set ) 让在Synchronized执行的边界的线程先不着急被挂起,然后在Synchronized的边界进行循环等待,这就是自旋.如果做了多次还没有获取到锁,在阻塞挂起.这样可能是一种好想法

自适应自旋锁

如果一些线程在执行过程中,自旋一定时间后,还没有获取到锁,但是在被挂起的时候,被执行的线程释放锁,那么再唤起阻塞的线程,依旧比较耗时. 那么可以通过多增加一些自旋的次数.如果能获取到锁,就没有必要线程被挂起和唤起来耗费时间了. 但是如果一味的增加,则会导致资源的浪费,那么可以根据上次获取锁的线程自旋的次数来确定本次需要自旋的次数,假设java线程规定自旋15次后,就被挂起,线程A在自旋15次后,又重新自旋了10次,在第10次的时候获取到锁,那么下次线程来的时候,就认为自己也多自旋10次就可以获取到锁,如果未获取到,则选择被挂起. 如果线程A自旋10次后依然没有获取到锁,那么下次线程来的时候,就不会选择多自旋10次.

偏向锁

在第一次有线程访问锁对象的时候,线程在获取到锁后,锁对象则记录该线程的线程ID,从而在下次线程来获取锁的时候,锁对象校验该线程ID是否是自己偏向的线程ID,如果是则该线程直接获取到锁.

轻量级锁

轻量级锁是由偏向所升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁.

重量级锁

java1.6以后,从轻量级锁升级为重量级锁,当锁的标志为重量级锁的时候,表示竞争压力比较大,而且同步代码块执行时间较长,此时没有获取到锁的线程都需要被挂起,在持有锁的线程释放锁以后,就重新唤起挂起的线程.

下面来看看Synchronized锁是怎么样升级锁的过程.

锁升级的过程

在介绍锁升级过的过程先讲讲java对象数据结构.

java对象数据结构

java对象在内存中分为,对象头,实例对象,对齐填充

  • 实例对象:实例对象是对象各种属性的类型,不管是从父类继承的还是子类的中定义的
  • 对齐填充:由于Hotspot规定对象的长度必须是8的整数倍,由于对象头都是8的整倍数(32位或者64位),但是实力对象是各种属性的类型,为了在不是8的倍数的时候,就对齐填充.
  • 对象头: 携带的是对象在虚拟机中所特有的信息 对象头中则是存放 对象的hashCode, 对象的锁标记位,还有GC标记 和偏向锁记录的线程ID

32位计算机的对象头在锁的变化情况时,对象头存储的信息

对象头markwork

偏向锁的加锁过程

  1. 第一次线程访问的时候,判断锁标志位是否是01,是否偏向锁为0 ,如果是则记录该线程的线程ID然后,执行同步代码
  2. 如果线程为可偏向状态,也就是是否偏向锁状态为1 所标志位为01 ,那么就比对锁对象的偏向锁线程ID是否和当前线程ID一致,如果一致则执行同步代码块
  3. 如果线程Id并没有指向该线程,则进行CAS竞争锁,如果竞争成功,则将markword中的偏向锁Id记录为当前线程ID,如果竞争失败,则执行步骤4
  4. 如果CAS获取偏向锁失败,则表示有竞争,当达到全局安全点(safepoint,全局安全点指当前时刻没有线程执行字节码,但是会发生stop the world,时间很短)时获取偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在全局安全点(safepoint)的同步代码继续执行. 撤销偏向锁的时候会引起stop the world

偏向锁的撤销过程

当有其他线程竞争锁的时候,就会执行偏向锁的撤销.
两个时机执行偏向锁:

  1. 在执行CAS获取锁的时候,获取锁失败,则有其他线程争夺
  2. 如果线程的偏向锁的偏向线程ID不是自己,则需要锁升级
    执行过程:
    当达到全局安全点的时候(safepoint),就会在当前线程的栈帧上创建锁记录的空间,将锁对象的markword到该线程的锁记录(lock record),然后将锁对象的markword指向该锁记录的栈帧的指针,至此同步线程可继续进行.

轻量级锁的转换过程

轻量级锁是由于竞争变大,偏向锁转换为轻量级锁的.
偏向锁的撤销会进入到轻量级锁的时候,有两种结果

  1. 变成轻量级锁的无锁状态(没有线程获取到锁)
  2. 变成轻量级锁的有所状态(在持有偏向锁偏向的线程正在执行同步代码的时候,锁进行升级)
    轻量级锁的加锁,类似于偏向锁的撤销,此时通过锁对象对象头指向的锁空间的锁记录的指针判断是否获取到锁(如果锁对象的markword指向当前线程的锁空间地址,则获取到锁.),如果没有获取到锁,jvm会使用自旋锁,通过自旋尝试重新获取锁,如果没有抢到锁,则进行锁升级(升级为重量级锁),如果获取到则执行同步代码

轻量级锁的释放

有轻量级锁转换为重量级锁的过程是在轻量级锁释放的过程中转换的. 之前在获取锁的时候copy对应的锁对象的markword,在释放锁的时候发现它在持有锁的时候有其他线程尝试获取锁,并且该线程对锁对象的markword做了修改,两者对比发现不一致,则切换中间级锁.
因为重量级锁被修改,所有的线程中锁记录的markword和锁对象的markword不一致,所以在进入mutex前,compare一下obj的markword状态,确认该markword是否被其他线程占用. 如果线程已经释放了markword,那么通过cas后,就可以直接进入线程 无需进入mutex.

轻量级锁的进入

尝试获取(CAS)轻量级锁的时候,如果线程正在被其他线程占用,那么就会修改markword为重量级锁.

注意:
等待轻量级锁的线程不会被挂起,而是一直自旋(CAS),并尝试修改锁对象的markword.

下面是Synchronized锁在锁标志位转换时的流程图.
Synchronized锁状态流转图
以上锁的转化过程不是我们代码能够控制的,而是通过对锁状态的分析,让我们可以优化自己线程的加锁操作

锁消除

jvm的运行时编译器在运行时如果检测到一些要求同步的方法上不可能发生共享数据竞争,则会去掉锁.

public void testDemo(){

    String ss = new String("ss");
    StringBuffer stringBuilder = new StringBuffer();
    StringBuffer dsss = stringBuilder.append("dsss").append(ss);
    System.out.println(dsss.toString());

}

StringBuffer是一个线程安全的类,在其append方法上是有Synchronized关键词的修饰的.

@Override
public synchronized StringBuffer append(String str) {
    toStringCache = null;
    super.append(str);
    return this;
}

但是由于 testDemo 这个方法中的StingBuffer是在方法内部的局部变量,随着方法的执行完毕而被释放,因此就不必要在执行append方法的时候加锁.

锁粗话

jvm在运行时编辑器运行时把需要邻近的代码块用同一个锁合并起来.
当JIT发现一系列连续的操作都对同一个对象反复加锁和解锁,甚至加锁操作出现在循环体中的时候,会将加锁同步的范围扩散(粗化)到整个操作序列的外部
如以下:

//锁粗化
public void testLockBlackDemo(){

    for (int i = 0; i < 5; i++) {
        synchronized (this){
            System.out.println(i);
        }
    }
}

public void testLockBlack2Demo(){
    synchronized (this){
        for (int i = 0; i < 5; i++) {
            System.out.println(i);
        }
    }
}

消除缓存行的伪共享

除了我们在写的同步锁和jvm内部锁,还有一种隐藏的锁,就是缓存行,它也被称为性能杀手.
在多核CPU的处理器中,每个CPU都有独立的一级缓存,二级缓存,甚至还有一个共享的三级缓存.为提高性能,CPU读写数据是以缓存行为最小单位读写的;32位的CPU缓存行为32字节,64位的CPU则是64字节.
例如:多个不需要同步的变量,因为存储在连续的32位或者64位字节中,当需要操作其中的一个变量,就需要将一个缓存行一起加载到CPU1的私有缓存中(最小读写单位是一个缓存行),被读入CPU1的缓存行相当于是对于主内存的一个copy,也相当于变相的在对于一个缓存行中的其他变量一起加了一把锁,这个缓存中的任何一个变量发生了变化,当CPU2需要读取这个缓存行的时候,就需要先将CPU1修改的缓存行更新的到主内存中,然后CPU2才能读取该缓存行,而CPU2可能需要更新的变量不是CPU1操作的变量. 这就把其他的变量给捆绑在一起了 .
为了防止伪共享,不同jdk版本有不同的策略.

  1. 在jdk1.7以前,将需要独占缓存行的变量前后添加一组long类型的变量,依靠这些无意义的变量填充够一个缓冲行
  2. 在jdk1.7时,会把这些无用的变量给优化掉,所以采用了一个声明很多long变量的类来实现.
  3. 在jdk1.8则是通过添加sun.misc.Contended注解来解决这个问题,若是想要该注解生效,必须在JVM中添加一下参数 -XX:-RestrictContended
    sun.misc.Contended注解会在变量前面添加128字节的padding将当前变量与其他变量进行隔离;

参考文章:
https://www.cnblogs.com/linghu-java/p/8944784.html

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值