线程安全(二)-锁升级


基础知识

超线程

一个ALU + 两组(Registers + PC)

ALU:算术逻辑单元(Arithmetic and Logic Unit)核心。

PC:程序计数器(Program Counter)保存下一条即将执行指令的内存地址,每解释执行完一条指令,pc的值就会自动被更新为下一条指令的地址。

Register:寄存器

ContextSwitch:线程上下文切换

内核态与用户态

内核态:与内核交互的核心操作,必须通过内核,如:网卡、显卡操作、拿锁(锁在内核是mutec)。

用户态:大多数应用程序在用户态。轻量级锁就在用户态。

线程阻塞的代价

java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统介入,需要在用户态与核心态之间切换,这种切换会消耗大量的系统资源,因为用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。

  • 如果线程状态切换是一个高频操作时,这将会消耗很多CPU处理时间;
  • 如果对于那些需要同步的简单的代码块,获取锁挂起操作消耗的时间比用户代码执行的时间还要长,这种同步策略显然非常糟糕的。
  • 阻塞再唤醒会导致线程发生两次上下文切换

synchronized会导致争用不到锁的线程进入阻塞状态,所以说它是java语言中一个重量级的同步操纵,被称为重量级锁,为了缓解上述性能问题,JVM从1.5开始,引入了轻量锁与偏向锁,默认启用了自旋锁,他们都属于乐观锁。

明确java线程切换的代价,是理解java中各种锁的优缺点的基础之一。

markword

在介绍java锁之前,先说下什么是markword,markword是java对象数据结构中的一部分,要详细了解java对象的结构可以点击这里,这里只做markword的详细介绍,因为对象的markword和java各种类型的锁密切相关;

markword数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit,它的最后2bit是锁状态标志位,用来标记当前对象的状态,对象的所处的状态,决定了markword存储的内容,如下表所示:

状态 标志位 存储内容
未锁定 01 对象哈希码、对象分代年龄
轻量级锁定 00 指向锁记录的指针
膨胀(重量级锁定) 10 执行重量级锁定的指针
GC标记 11 空(不需要记录信息)
可偏向 01 偏向线程ID、偏向时间戳、对象分代年龄

锁升级

无锁态

当我们new一个对象的时候,就是无锁态,也就是房间门敞开的,谁都可以进进出出。

偏向锁

锁这种东西我们是需要去商家买锁。同样的,锁这种重量级资源也是稀缺的,数量有限,我们需要向操作系统申请。这种重量级的方案显然不应该上来就直接使用。

在竞争不是很激烈的情况下,一般线程使用过后就释放了,也就没必要申请重量级锁,而且一般再次使用的还会是它。

那么简单的方法是:偏向锁。偏向于第一个访问的线程。进来的线程在房间门上贴个标签,写上自己的名字。比如说 :某栋大楼上挂着腾讯的牌子,别人一看就知道,“哦 这是腾讯的大楼,我要去的是隔壁的阿里”。

所谓的“贴个标签”指的是:在 markword 中用54位记录一个指向当前线程的指针,也就是标识是属于此线程的。贴的标签,标识自己的线程 id。

它通过消除资源无竞争情况下的同步原语,进一步提高了程序的运行性能。

获取偏向锁过程

  • 1)访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01,确认为可偏向状态。

  • 2)如果为可偏向状态,则测试线程ID是否指向当前线程,如果是,进入步骤5,否则进入步骤3。

  • 3)如果线程ID并未指向当前线程,则通过CAS尝试修改为自己的线程ID。如果修改成功,则将Mark Word中线程ID设置为当前线程ID,然后执行5;如果竞争失败,执行4。

  • 4)如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,

    • 如果持有偏向锁线程不活跃,恢复到无锁态

    • 如果线程依然活跃,马上执行线程的操作栈,检查对偏向锁对象的使用情况

      • 如果仍然需要持有偏向锁,则表示有竞争,升级为轻量级锁

      • 如果不存在使用偏向锁,恢复到无锁态

    偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。(撤销偏向锁的时候会导致stop the word)

  • 5)执行同步代码

偏向锁特点

偏向锁不可重偏向 批量偏向 批量撤销。

偏向锁由于有锁撤销的过程revoke,会消耗系统资源。所以,在锁争用特别激烈的时候,用偏向锁未必效率高。还不如直接使用轻量级锁。

偏向锁延时

默认情况 偏向锁有个时延,默认是4秒。
why? 因为JVM虚拟机自己有一些默认启动的线程,里面有好多sync代码,这些sync代码启动时就知道肯定会有竞争,如果使用偏向锁,就会造成偏向锁不断的进行锁撤销和锁升级的操作,效率较低。

轻量级锁

轻量级锁也叫无锁、自旋锁

在偏向锁状态下,当有另外一个或多个线程竞争(只要发生任意一个竞争)就自动升级为轻量级锁

  • 1)首先,撤销偏向锁状态,把门上的标签撕下来。
  • 2)然后,每个线程的线程栈中,都生成一个 Lock Record(锁记录,放的是对Mark Word的复制 Displaced Mark Word)。
  • 3)再然后多个线程通过CAS抢占,看哪个线程可以先把LR的指针写到轻量级锁中,轻量级锁中记录的是:指向拥有锁的,线程栈中的Lock Record 的指针。
  • 4)如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态
  • 5)抢占失败的线程,自旋,等待机会。

抢占使用的是自旋的方式(CAS)

  • 线程不断的获取轻量级锁中的记录,修改为指向自己的。

  • 然后尝试将修改后的结果写回去,

  • 如果原来的值还是当初读到的旧值,没有被别人改变,那就把自己修改后的写进去,抢占成功。

    否则就自旋,重复。

但是不能就这样一直自旋下去,因为自旋实在是太消耗CPU资源了。所以需要下一步的锁升级。

自适应自旋

在JDK 1.6 之后由JVM自行控制自旋锁升级的时机。也就是自适应自旋 (Adaptive Self Spinning)。

自适应自旋锁意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

重量级锁

升级重量级锁:-> 向操作系统申请资源,linux mutex , CPU从3级-0级系统调用,线程挂起,进入等待队列,等待操作系统的调度,然后再映射回用户空间

synchronized

synchronization,其使用监视器 (monitor) 来实现。java中的每个对象都关联了一个监视器,线程可以对其进行加锁和解锁操作。在同一时间,只有一个线程可以拿到对象上的监视器锁。如果其他线程在锁被占用期间试图去获取锁,那么将会被阻塞直到成功获取到锁。同时,监视器锁可以重入,也就是说如果线程 t 拿到了锁,那么线程 t 可以在解锁之前重复获取锁;每次解锁操作会反转一次加锁产生的效果。

public class T {
    static volatile int i = 0;

    public static void n() { i++; }

    public static synchronized void m() {}

    public static void main(String[] args) {
        for(int j=0; j<1000_000; j++) {
            m();
            n();
        }
    }
}

java -XX:+UnlockDiagonositicVMOptions -XX:+PrintAssembly T

C1 Compile Level 1 (一级优化)

C2 Compile Level 2 (二级优化)

找到m() n()方法的汇编码,会看到 lock comxchg …..指令

synchronized vs Lock (CAS)

 在高争用 高耗时的环境下synchronized效率更高
 在低争用 低耗时的环境下CAS效率更高
 synchronized到重量级之后是等待队列(不消耗CPU)
 CAS(等待期间消耗CPU)

 一切以实测为准

锁优化

以上介绍的锁不是我们代码中能够控制的,但是借鉴上面的思想,我们可以优化我们自己线程的加锁操作;

减少锁的时间

不需要同步执行的代码,能不放在同步块里面执行就不要放在同步块内,可以让锁尽快释放;

使用读写锁

ReentrantReadWriteLock 是一个读写锁,读操作加读锁,可以并发读,写操作使用写锁,只能单线程写;

消除缓存行的伪共享

除了我们在代码中使用的同步锁和jvm自己内置的同步锁外,还有一种隐藏的锁就是缓存行,它也被称为性能杀手。
在多核cup的处理器中,每个cup都有自己独占的一级缓存、二级缓存,甚至还有一个共享的三级缓存,为了提高性能,cpu读写数据每次都会读取数据所在的数据块,是以缓存行为最小单元读写的;32位的cpu缓存行为32字节,64位cup的缓存行为64字节,这就导致了一些问题。
例如,多个不需要同步的变量因为存储在连续的32字节或64字节里面,当需要其中的一个变量时,就将它们作为一个缓存行一起加载到某个cup-1私有的缓存中(虽然只需要一个变量,但是cpu读取会以缓存行为最小单位,将其相邻的变量一起读入),被读入cpu缓存的变量相当于是对主内存变量的一个拷贝,也相当于变相的将在同一个缓存行中的几个变量加了一把锁,这个缓存行中任何一个变量发生了变化,当cup-2需要读取这个缓存行时,就需要先将cup-1中被改变了的整个缓存行更新回主存(即使其它变量没有更改),然后cup-2才能够读取,而cup-2可能需要更改这个缓存行的变量与cpu-1已经更改的缓存行中的变量是不一样的,所以这相当于给几个毫不相关的变量加了一把同步锁
为了防止伪共享,不同jdk版本实现方式是不一样的:

  • 在jdk1.7之前会 将需要独占缓存行的变量前后添加一组long类型的变量,依靠这些无意义的数组的填充做到一个变量自己独占一个缓存行

  • 在jdk1.7因为jvm会将这些没有用到的变量优化掉,采用继承一个声明了好多long变量的类的方式来实现;

  • 在jdk1.8中通过添加sun.misc.Contended注解来解决这个问题,若要使该注解有效必须在jvm中添加以下参数:
    -XX:-RestrictContended

    sun.misc.Contended注解会在变量前面添加128字节的padding将当前变量与其他变量进行隔离

减少锁的粒度

它的思想是将物理上的一个锁,拆成逻辑上的多个锁,增加并行度,从而降低锁竞争。它的思想也是用空间来换时间;

java中很多数据结构都是采用这种方法提高并发操作的效率:

ConcurrentHashMap

java中的ConcurrentHashMap在jdk1.8之前的版本,使用一个Segment 数组

Segment< K,V >[] segments

Segment继承自ReenTrantLock,所以每个Segment就是个可重入锁,每个Segment 有一个HashEntry< K,V >数组用来存放数据,put操作时,先确定往哪个Segment放数据,只需要锁定这个Segment,执行put,其它的Segment不会被锁定;所以数组中有多少个Segment就允许同一时刻多少个线程存放数据,这样增加了并发能力。

LongAdder(推荐)

LongAdder 实现思路也类似ConcurrentHashMap,LongAdder有一个根据当前并发状况动态改变的Cell数组,Cell对象里面有一个long类型的value用来存储值;
开始没有并发争用的时候或者是cells数组正在初始化的时候,会使用cas来将值累加到成员变量的base上,在并发争用的情况下,LongAdder会初始化cells数组,在Cell数组中选定一个Cell加锁,数组有多少个cell,就允许同时有多少线程进行修改,最后将数组中每个Cell中的value相加,在加上base的值,就是最终的值;cell数组还能根据当前线程争用情况进行扩容,初始长度为2,每次扩容会增长一倍,直到扩容到大于等于cpu数量就不再扩容,这也就是为什么LongAdder比cas和AtomicInteger效率要高的原因,后面两者都是volatile+cas实现的,他们的竞争维度是1,LongAdder的竞争维度为“Cell个数+1”为什么要+1?因为它还有一个base,如果竞争不到锁还会尝试将数值加到base上;

LinkedBlockingQueue

LinkedBlockingQueue也体现了这样的思想,在队列头入队,在队列尾出队,入队和出队使用不同的锁,相对于LinkedBlockingArray只有一个锁效率要高;

拆锁的粒度不能无限拆,最多可以将一个锁拆为当前cup数量个锁即可;

锁消除 lock eliminate

public void add(String str1,String str2){
         StringBuffer sb = new StringBuffer();
         sb.append(str1).append(str2);
}

我们都知道 StringBuffer 是线程安全的,因为它的关键方法都是被 synchronized 修饰过的,但我们看上面这段代码,我们会发现,sb 这个引用只会在 add 方法中使用,不可能被其它线程引用(因为是局部变量,栈私有),因此 sb 是不可能共享的资源,JVM 会自动消除 StringBuffer 对象内部的锁。

锁粗化 lock coarsening

public String test(String str){

       int i = 0;
       StringBuffer sb = new StringBuffer():
       while(i < 100){
           sb.append(str);
           i++;
       }
       return sb.toString():
}

JVM 会检测到这样一连串的操作都对同一个对象加锁(while 循环内 100 次执行 append,没有锁粗化的就要进行 100 次加锁/解锁),此时 JVM 就会将加锁的范围粗化到这一连串的操作的外部(比如 while 虚幻体外),使得这一连串操作只需要加一次锁即可。

锁降级(不重要)

https://www.zhihu.com/question/63859501

其实,只被VMThread访问,降级也就没啥意义了。所以可以简单认为锁降级不存在!

参考:

https://www.cnblogs.com/linghu-java/p/8944784.html

http://mashibing.com

《码出高效》


  TOC