魅力峰值

导航

线程安全的实现方法

线程安全的实现方法

什么是线程安全?

线程安全:《JVM的高性能与最佳实践》有说,“多个线程访问同一个对象,如果我们不用考虑线程运行时的调度和交替执行,不用做额外的同步,或者在调用时候不用进行协调操作,调用的结果总是正确的结果,那么这个对象是线程安全的”

线程安全的对象具有以下特征:对象本身已经封装了所有必要的正确性保障手段,对象的使用者不用考虑多线程的问题。

 

Java的线程安全定义有哪些?

1、不可变

在Java(JDK1.5之后)中,不可变的对象一定是线程安全的。无论是对象的方法还是对象的使用者,都不必采用线程安全的措施。比如使用final关键字,只要一个对象正确的被构建出来,那其外部的可见状态,永远不会被改变,也永远不会看到它在多喝线程中不一致的状态。如果线程共享的数据是基础数据类型,只要在定义的时候使用final关键字修饰即可,如果共享的是一个对象,就需要保证对象的行为不对其产生影响才可以。比如在对象的带有状态的变量全部声明为final。

扩展:为int、float等基础类型前边加final是该对象的值不可变,在Map、类等对象前加final,是对象的引用不可变。String类型的数据本身是不可变的。

 

2、绝对的线程安全

绝对的线程安全定义:不管运行环境如何,调用者都不需要进行额外的线程安全措施。

在javaAPI中,标注自己是安全的类,大多都不是绝对的线程安全的,比如Vector,类中属性大多都加了synchronized,但是还达不到绝对的线程安全。

 

3、相对的线程安全

相对的线程安全就是我们通常意义上的线程安全,需要对这个对象的单独操作是安全的不需要做额外的保障措施,但是对一些特定顺序的连续调用,需要做一些额外的保障措施。大部分的线程安全类都是这样的比如,hashTable、Vector等

 

4、线程兼容

指的是对象本事是线程不安全的,但是调用端正确的调用手段可以达到线程安全的目的,JavaAPI中大部分的类都可以达到这个目的,比如ArrayList、HashMap等

 

5、线程对立

指的是无论采取什么并发措施,都无法达到线程安全的目的,比如Thread类中的 suspend() 和 resume(),如果两个线程同时持有一个线程的对象一个去suspend()一个去resume(),如果并发进行的话,可能会导致死锁。

 

线程安全如何实现?

1、互斥同步(阻塞同步)

 (1)synchronized关键字是常见的阻塞手段,synchronized编译之后会在代码块前后添加minitorenter和monitorexit指令,synchronized根据修饰的类或者对象,进行锁定,尝试获取锁,成功之后monitorenter会将锁计数器加一,monitorexit指令会将计数器减一,为0则释放锁。synchronized指令对同一个线程是可重入的。线程执行完之前,会阻塞后边的线程。

值得注意的是,Java的线程都是映射到操作系统(OS)上的,如果要阻塞或者唤醒一个线程都需要操作系统来帮忙,从用户态转换到核心态,很耗cpu时。对于简单的代码块,状态转换比代码更耗时。

(2)java.util.concurrent中的可重入锁一样可以说实现同步,功能与synchronized类似,一样具有具有可重入性、互斥性,区别是一个是API层面的锁,一个是原生语法层面的。相比synchronized,ReentrantLock添加了更多的功能,主要是三点:

  一是等待可中断(持有锁的线程长时间不释放的时候,等待的线程可执行其它操作),

  二是可实现公平锁(构造参数的boolean值代表是否公平锁)(可根据等待锁的时间顺序依次获取锁,syncharonized是非公平锁)

  三是可指定解锁条件(ReentrantLock可绑定多个Condition,只需要lock.newCondition()即可)

两个锁对比,synchronized在多线程高并发的情况下,性能下降的非常严重,ReentranLock是最佳选择,synchronized有很多要优化的地方

2、非阻塞同步

互斥同步是属于悲观锁的并发策略,因为它总认为会出现并发问题,所以做同步措施。最新的硬件指令集提供了一个基于冲突检测的乐观锁并发策略:先进行操作,如果有没有冲突,就操作成功,如果有冲突,在进行补偿(常见的是重新操作,直到成功)。

这种乐观锁的同步策略需要将操作和冲突检测放在一个指令集里边。

常见的类似指令 :

   (1)测试并设置(Test And Set)

   (2)获取并增加(Fetch And Increment)

   (3)交换(Swap)

   (4)比较并交换(Compare And Swap)

   (5)加载链接/条件存储(Load Linked/Store Conditional)

CAS指令需要三个参数(V、A、B)V是内存地址,A是旧值,B是新值,当且仅当V符合A的值时候,cpu会将新值B更新到地址V,这是连续的原子操作

JDK1.5之后,在Java的sun.misc.Unsafe提供CAS操作,如:CompareAndSwapInt()、compareAndSwapLong()等

CAS的漏洞:“ABA问题”,原值A,地址目前也是A,但是无法确认A是否是被修改过的A,还是原来的A

3、无同步方案

一些代码天生是线程安全的,因此不需要进行线程安全的操作。如:

(1)可重入代码:一些代码可以运行的时候,可以中断执行其它代码,在获取线程的执行权之后继续执行,不会有错误。

可重入代码有以下特征:不依赖存储在堆上的数据和公共资源、用到的数据是参数传入,不调用不可重入数据。

可如此判断:一个方法,它的结果是可以预测的,输入了相同的数据,既可以返回预测的数据。

(2)线程本地存储:如果一段代码必须与其它线程共享数据,我们就看看这数据能否限制在同一个线程内,如果可以的话,即可以无须同步。

 

 

JVM的锁优化

1、自旋锁与自适应锁

线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。

所以引入自旋锁。 

何谓自旋锁?

  所谓自旋锁,就是让该线程等待一段时间,不会被立即挂起(就是不让前来获取该锁(已被占用)的线程立即阻塞),看持有锁的线程是否会很快释放锁。

怎么等待呢?即执行一段无意义的循环(自旋)。自旋锁默认自旋10次。

JDK1.6引入了自适应的自旋锁,所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

  线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。

2、锁消除

 

为了保证数据的完整性,我们在进行操作时需要对这部分操作进行同步控制,但是在有些情况下,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内部的加锁操作消除。

 

3、锁粗化

 

我们知道在使用同步锁的时候,需要让同步块的作用范围尽可能小,仅在共享数据的实际作用域中才进行同步。这样做的目的是为了使需要同步的操作数量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。 

 

  在大多数的情况下,上述观点是正确的,LZ也一直坚持着这个观点。但是如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗化的概念。 

 

  那什么是锁粗化?

 

就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。

 

  如上面实例:vector每次add的时候都需要加锁操作,JVM检测到对同一个对象(vector)连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到for循环之外。

4、轻量级锁

引入轻量级锁的主要目的是在多没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗 

当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁,其步骤如下:获取锁。

  1. 判断当前对象是否处于无锁状态(hashcode、0、01),若是,则JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word);否则执行步骤(3);

  2. JVM利用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指正,如果成功表示竞争到锁,则将锁标志位变成00(表示此对象处于轻量级锁状态),执行同步操作;如果失败则执行步骤(3);

  3. 判断当前对象的Mark Word是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁,锁标志位变成10,后面等待的线程将会进入阻塞状态; 

释放锁轻量级锁的释放也是通过CAS操作来进行的,主要步骤如下:

  1. 取出在获取轻量级锁保存在Displaced Mark Word中的数据;

  2. 用CAS操作将取出的数据替换当前对象的Mark Word中,如果成功,则说明释放锁成功,否则执行(3);

  3. 如果CAS操作替换失败,说明有其他线程尝试获取该锁,则需要在释放锁的同时需要唤醒被挂起的线程。

  轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据。轻量级锁在当前线程的栈帧中建立一个名为锁记录的空间,用于存储锁对象目前的指向和状态。如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。

 5、偏向锁

 引入偏向锁主要目的是:为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径。上面提到了轻量级锁的加锁解锁操作是需要依赖多次CAS原子指令的。那么偏向锁是如何来减少不必要的CAS操作呢?我们可以查看Mark work的结构就明白了。

只需要检查是否为偏向锁、锁标识为以及ThreadID即可,处理流程如下:获取锁。

  1. 检测Mark Word是否为可偏向状态,即是否为偏向锁1,锁标识位为01;

  2. 若为可偏向状态,则测试线程ID是否为当前线程ID,如果是,则执行步骤(5),否则执行步骤(3);

  3. 如果线程ID不为当前线程ID,则通过CAS操作竞争锁,竞争成功,则将Mark Word的线程ID替换为当前线程ID,否则执行线程(4);

  4. 通过CAS竞争锁失败,证明当前存在多线程竞争情况,当到达全局安全点,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块;

  5. 执行同步代码块。 

释放锁偏向锁的释放采用了一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。偏向锁的撤销需要等待全局安全点(这个时间点是上没有正在执行的代码)。

其步骤如下:

  1. 暂停拥有偏向锁的线程,判断锁对象石是否还处于被锁定状态;

  2. 撤销偏向苏,恢复到无锁状态(01)或者轻量级锁的状态。

 

posted on 2020-06-03 16:02  魅力峰值  阅读(1087)  评论(0编辑  收藏  举报