深入理解Java虚拟机——第十三章——高效并发

线程安全

当多个线程访问一个对象时,如果不考虑这些线程在运行环境下的调度和交替执行,也不需要进行额外的同步,或者在调用地方进行额外的协调操作,调用这个对象的行为都可以获得正确的结果,那么这个对象是线程安全的。

Java的线程安全

各种操作共享的数据分类5类:

  1. 不可变:不可变对象一定是线程安全的。如基本数据类型定义为final就是不可变。String、Number、Long等数值都是不可变的,但Number的原子类AtomicInteger并非不可变的。
  2. 绝对线程安全:不管运行时环境如何,都不需要任何额外的同步措施。如Vector类虽然是线程安全的,因为其方法都加上了synchronized,但是如果在外面用两个线程调用它,一个访问元素一个删除元素,那么如果外面不使用同步的话就会使得访问到刚被删除的元素而报错。因此Vector不是绝对线程安全。
  3. 相对线程安全:保证对象单独操作是线程安全的。如Vector就是相对线程安全的。
  4. 线程兼容:对象本身不是线程安全的,但是调用的时候用同步手段可以保证线程安全。如ArrayList和HashMap。
  5. 线程对立:无论是否采取同步措施,都无法在多线程环境中并发使用的代码。如Thread的suspend()和resume()方法,同时操作一个线程进行中断和恢复,一旦并发,无论是否同步都是存在死锁风险。即一个线程suspend挂起自己(suspend不会释放所持有的锁除非进行resume),如果resume所在的线程在resume之前因想要获取suspend线程里的锁而阻塞,那么就发生了死锁。参考https://www.jianshu.com/p/7a123f212ca1

线程安全的实现方法

互斥同步

互斥是实现同步的一种手段。需要阻塞和唤醒,使得性能降低,也称阻塞同步,是悲观锁。无论共享数据是否会发生竞争都需要加锁。

最基本互斥手段是synchronized。会在同步块的前后形成monitorenter和monitorexit,在执行monitorenter时尝试去获取锁,如果已被自己持有或者未被持有则锁计数器加1,执行monitorexit就会减1,直到为0才释放锁。如果获取锁失败则会处于阻塞状态。

除了synchronnized外还有JUC的重入锁(ReentrantLock),都具备线程重入特性,只是代码写法上有区别,一个为API层面的互斥锁(lock和unlock方法配合try/finally语句实现),一个是原生语法层面的互斥锁。RenetrantLock多了3个高级功能:

  • 等待可中断:当持有锁的线程长期不释放锁的时候,等待锁的线程可以放弃等待,改为处理其它事情
  • 公平锁:多个线程在等待一个锁时,按申请的时间顺序来获得锁。而非公平锁是每个等待线程都有机会获得锁,synchronized是非公平锁
  • 锁绑定条件:一个Reentrant可以绑定多个Condition对象,而synchronized要和多于一个条件关联的时候,就不得不额外添加一个锁,Reentrant只需多次调用newCondition。

非阻塞同步

乐观锁。其并发策略时先进行操作,如果没有线程用共享数据,那操作就成功;如果有争用,那么产生冲突,就采取其它措施补偿(常见的补偿是不断重试直到成功为止)。因此就不要把线程挂起。

乐观锁需要保证操作和冲突检测具有原子性,如果使用互斥同步来保证就失去意义了,因此需要硬件来完成这件事。如CAS指令。

CAS有三个操作数:内存地址A、旧的预期值O、新值N。当且仅当A符合旧预期值O时才进行更新V为新值N,并返回旧值O,如果不符合则不进行更新。该操作是一个原子操作。

无同步方案

保证线程安全不一定就需要同步,二者无因果关系。如果一个方法不涉及共享数据,那么就无须任何同步。例如下面两类代码是天生线程安全的:

  • 可重入代码:也叫纯代码,可以在代码执行的任何时候中断,转而去执行另一段代码(包括递归调用自己),在控制权返回后原来的程序不会出现错误。可重入代码是都是线程安全的,反过来就不是。有个简单的判断方法:如果方法的返回结果是可预测的,只要输入相同的数据都能返回相同的结果,那么就满足可重入的要求。例如不依赖堆上的数据和公用的系统资源、不调用不可重入代码、用到的状态量都是参数传入。
  • 线程本地存储:如果代码所需的数据必须与其它代码共享,那么就看是否能保证将这些共享数据的代码在同一个线程中执行。例如消费队列都是保证消费过程在一个线程中完成,具体为web中一个请求对应一个服务器线程。

锁优化

锁优化技术:自适应自旋、锁消除、锁粗化、轻量级锁、偏向锁等、

自旋锁和自适应自旋

互斥同步最大的性能消耗就是线程的挂起和恢复,都需要转入内核态完成。大部分共享数据的锁定状态只持续很短的时间,因此为了这段时间取挂起和恢复不值得,所以可以让后面请求的锁“等待一下”,但不放弃处理器的执行时间,只需执行忙循环(自旋),这就是所谓的自旋锁。

自旋不能代替阻塞。如果自旋的时间开销超过了线程挂起和恢复的时间开销,那么就白白消耗处理器资源。默认自旋10次,可-XX:PreBlockSpin更改。JDK1.6后引入自旋锁,自旋时间不是固定的,而是由上一次在同一个锁上的自旋时间以及锁的拥有者状态来决定。如果上一次自旋成功且线程正在运行中,则会认为此次自旋可能成功,因此会让自旋持续更长时间,如果一个锁的自旋很少成功则以后获取可能直接省略自旋直接挂起。

锁消除

锁消除是指在即时编译时,对一些代码上要求同步,但是被检测到不存在共数据竞争的锁进行消除。例如StringBuffer的append有同步块,其引用sb不会逃逸到concatString外,其他线程无法访问到sb来进行操作,因此锁可以被安全消除。sb放到实例变量去就得加锁处理。

public String concatString(String s1, String s2, String s3) {
        StringBuffer sb = new StringBuffer();
        sb.append(s1);
        sb.append(s2);
        sb.append(s3);
        return sb.toString();
}

锁粗化

原则上是推荐将同步的作用范围变小,只在共享数据的实际作用域才发生同步。但如果在一系列操作对同一个对象反复加锁解锁,甚至加锁出现在for循环里,那么即使没竞争,频繁的互斥同步也会导致性能损耗。例如上面的append,虚拟机就会把加锁范围变成第一个append扩展到最后一个append。

轻量级锁

轻量级锁是相对使用操作系统互斥量来实现的传统锁而言的,传统锁是重量级锁。轻量级锁是减少使用操作系统的互斥量产生的性能损耗。(使用自旋锁)

如果一开始线程获取锁时,对象头标志未锁定,则用CAS操作使对象头标志为轻量锁,则拥有了该对象的锁。如果更新失败,则判断对象的mark word是否指向当前线程的栈帧,是则表明已经拥有这个锁,直接进入同步代码块,否则说明该锁被其它线程占了。如果两条以上的线程争同一个锁,那么轻量级锁就变成重量级锁。

轻量级锁提升同步性能的依据是“大部分锁,在整个同步周期是不存在竞争的”,这是一个经验数据。没有竞争,则使用CAS操作就避免了使用互斥的开销。

偏向锁

锁会偏向于第一个获取它的线程,如果在接下来的执行过程中,锁没被其它线程获取,则持有偏向锁的线程不需要再进行同步。当另外一个线程尝试获取该锁时,则根据锁对象是否被锁定转换为轻量级锁或未锁定状态。

posted @ 2019-07-30 15:25  大尾鲈鳗  阅读(125)  评论(0编辑  收藏  举报