深入理解Java虚拟机之线程安全与锁优化

1.概述
并发正确性与高效性
2.线程安全
定义:当多个线程访问一个对象时,如果不考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的。

这个定义比较严谨,它要求线程安全的代码都具备一个特征:代码本身封装了所有必要的正确性保障手段(如互斥同步等),令调用者无需关心多线程的问题,更无需自己采取任何措施来保证多线程的正确调用。弱化这个定义,“调用这个对象的行为”限定为“单次调用”,这个定义的其他描述成立,就认为线程安全

2.1Java语言中的线程安全
操作线程共享的数据可以根据“安全程度”强弱分为5类

  1. 不可变 不可变的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再采取任何的线程安全保障措施。(共享数据如果是一个基本数据类型,那么只要在定义时使用final关键字修饰它就可以保证它是不可变的;如果共享数据是一个对象,那就需要保证对象的行为不会对其状态产生任何影响)
  2. 绝对线程安全 在Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全。如java.util.Vector是一个线程安全的容器,它的add(),get(),size()这类方法都是被synchronized修饰的,即使它的所有方法都被修饰为同步也并不意味着调用它的时候永远都不再需要同步手段了。比如在多线程环境下,不在方法调用端做额外的同步,一个线程访问序号i的元素,但是这个元素恰好在错误的时间里删除了,那么就会抛出ArrayIndexOutOfBoundsException。
  3. 相对线程安全 相对线程安全就是我们通常意义上的线程安全,他需要保证对这个对象单独操作时线程安全的,我们在调用的时候不需要做额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。(Vector,HashTable,Collections的synchronizedCollection())方法包装的集合等。
  4. 线程兼容 线程兼容指对象本身并不是线程安全的,但是可以在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用,我们平常说一个类不是线程安全的,绝大多数时候是指这一种情况。Java API大部分类是线程兼容的,如与Vector和HashTable相对应的集合类ArrayList和HashMap等。
  5. 线程对立 线程对立是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。

2.2线程安全的实现

1.同步互斥
同步是指多个线程并发访问数据时,保证共享数据在同一个时刻只被一个(或者是一些,使用信号量的时候)线程使用。互斥可以实现同步。
互斥同步手段:使用synchronized关键字,synchronized关键字经过编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象。

在执行monitorenter指令时,首先要尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1,相应的,在执行monitorexit指令时会将锁计数器减1,当计数器为0时,锁就会被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到对象锁被另外一个线程释放为止。

Java线程是映射到操作系统的原生线程之上的,如果要阻塞或者唤醒一个线程,都需要操作系统来帮忙完成,需要由用户态转换到核心态中,因此状态转换需要耗费很多处理器时间。

使用ReentrantLock来实现同步
基本用法:ReentrantLock与synchronized很相似,都具备线程重入特性。代码写法上ReentrantLock表现为API层面的互斥锁(lock()和unlock()方法配合try/finally语句块来完成),synchronized表现为原生语法层面的互斥锁。

ReentrantLock比synchronized增加的高级功能

  • 等待可中断 当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情,可中断特性对于处理执行时间特别长的同步块很有帮助
  • 公平锁 指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待的线程都有机会获得锁。synchronized中的锁是非公平的,ReentrantLock默认是非公平的,但可以通过带布尔值的构造函数要求使用公平锁。
  • 锁绑定 指一个ReentrantLock对象可以同时绑定多个Condition对象,而在synchronized中,锁对象的wait()和notify()或notifyAll()方法可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外地添加一个锁,而ReentrantLock只需要多次调用newCondition()方法即可。

两种手段的选择:优先考虑使用synchronized来进行同步

关于两种锁的性能问题,jdk1.5与单核处理器,jdk1.5与双Xeon处理器环境下的吞吐量对比试验
在这里插入图片描述

在这里插入图片描述
2.非阻塞同步
基于冲突检测的乐观并发策略:先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采取其他的补偿措施(最常见的补偿措施就是不断地重试,直到成功为止),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作成为非阻塞同步。(依靠于硬件指令集的发展)

操作和冲突检测两个步骤需要具备原子性,必须由硬件来保证。硬件保证一个从语义看起来需要多次操作的行为只需要通过一条指令就能完成,这类常用指令有:

  • 测试并设置(Test-and-Set)
  • 获取并添加(Fetch-and-Increment)
  • 交换(Swap)
  • 比较并交换(Compare-and-Swap,简称CAS)
  • 加载链接/条件存储(Load-Linked/Store-Conditional,简称称LL/SC)

前面3条是20世纪就已经存在大多数指令集之中的处理器指令,后面两条是现代处理器新增的。

CAS指令需要3个操作数,分别是内存位置(在Java中可以简单理解为变量的内存地址,用V表示),旧的预期值(用A表示)和新值(用B表示)。CAS指令执行时,并且仅当V符合旧预期值A时,处理器用新值B更新V的值,否则它就不执行更新,但是无论是否更新了V的值,都会返回V的旧值,上述的处理过程是一个原子操作。

3.无同步方案
要保证线程安全,并不一定要同步。

1.可重入代码:也叫纯代码,可以在代码执行的任何时刻中断它,转而取执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。

共同特征:不依赖存储在堆上的数据和公用的系统资源,用到的状态量都由参数传入,不调用非可重入的方法等。

代码可重入性的判定:如果一个方法,它的返回结果是可以预测的,只要输入了相同的数据,就能返回相同的结果,那它就满足可重入性的要求,当然也就是线程安全的。

2.线程本地存储:如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行?如果能,我们就把共享的数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。

具体实现:每一个线程的Thread对象中都有一个ThreadLocalMap对象,这个对象存储了一组以ThreadLocal.threadLocalHashCode为键,以本地线程变量为值的K-V值对,ThreadLocal对象就是当前线程的ThreadLocalMap的访问入口,每一个ThreadLocal对象都包含了一个独一无二的threadLocalHashCode值,使用这个值就可以在线程K-V值对中找回对应的本地线程变量。

3.3锁优化
为了在线程更高效地共享数据,以及解决竞争问题,从而提高程序的执行效率,有下面几种锁优化
1.自旋锁与自适应锁
问题引入:互斥同步对性能影响最大的就是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性能带来了很大的压力。

解决的有利条件:共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。

自旋锁技术:如果物理机器有一个以上的处理器,能让两个或以上的线程同时并发执行,我们就可以让后面请求锁的那个线程“稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需要让线程执行一个忙循环(自旋)。

自旋锁本身虽然避免了线程切换的开销,但是它是要占用处理器时间的,如果占用时间短,自旋锁效果就好,如果时间长,那么自旋过程会白白消耗处理器资源,而不会做任何有用的工作。可以使用自旋次数来控制限度。

自适应自旋锁:自旋时间不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能会再次成功,进而它将允许自旋等待持续更长的时间,多些数目的循环。另外,如果对于某个锁,自旋很少成功获得过,那在以后获取这个锁时将可能省略掉自旋过程,以避免浪费处理器时间。
2.锁消除:指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。
判定依据:如果一段代码中堆上的所有数据都不会逃逸出去被其他线程访问到,可把它们当做栈上数据对待,即线程私有的,无须同步加锁。
3.锁粗化
一般情况下,会将同步块的作用范围限制到只在共享数据的实际作用域中才进行同步,使得需要同步的操作数量尽可能变小,保证就算存在锁竞争,等待锁的线程也能尽快拿到锁。

但如果反复操作对同一个对象进行加锁和解锁,即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗,此时,虚拟机将会把加锁同步的范围粗化到整个操作序列的外部,这样只需加一次锁。
4.轻量级锁
目的:在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗,注意不是用来代替重量级锁的。

首先先理解HotSpot虚拟机的对象头的内存布局:分为两部分,第一部分用于存储对象自身的运行时数据,这部分被称为Mark Word,是实现轻量级锁和偏向锁的关键。如哈希码、GC分代年龄等。
另外一部分用于存储指向方法区对象类型数据的指针,如果是数组对象还会有一个额外的部分用于存储数组长度。
在这里插入图片描述

加锁过程:代码进入同步块时,如果同步对象未被锁定(锁标志位为01),虚拟机会在当前线程的栈帧中建立一个名为Lock Record的空间,用于存储锁对象Mark Word的拷贝。如下图。
在这里插入图片描述
之后虚拟机会尝试用CAS操作将对象的Mark Word更新为指向Lock Record的指针。若更新动作成功,那么当前线程就拥有了该对象的锁,且对象Mark Word的锁标志位变为00,即处于轻量级锁定状态;反之,虚拟机会先检查对象的Mark Word是否指向当前线程的栈帧,若当前线程已有该对象的锁,可直接进入同步块继续执行,否则说明改对象已被其他线程抢占。如下图。
在这里插入图片描述

另外,如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志位变为10,Mark Word中存储的就是指向重量级锁的指针,后面等待锁的线程也要进入阻塞状态。

解锁过程:若对象的Mark Word仍指向着线程的Lock Record,就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来。若替换成功,那么就完成了整个同步过程;反之,说明有其他线程尝试获取该锁,那么就要在释放锁的同时唤醒被挂起的线程。

优点:因为对于绝大部分的锁,在整个同步周期内都是不存在竞争的,所以轻量级锁通过使用CAS操作消除同步使用的互斥量。
5.偏向锁
目的:消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。

偏向锁的“偏”,这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他线程获取,则持有偏向锁的线程将永远不需要再进行同步。

加锁过程:启用偏向锁的锁对象在第一次被线程获取时,Mark Word的锁标志位会被设置为01,即偏向模式,同时使用CAS操作把获取到这个锁的线程ID记录在对象的Mark Word中。若操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时都可不再进行任何同步操作。

解锁过程:当有另外的线程去尝试获取这个锁时,偏向模式宣告结束,根据锁对象目前是否处于被锁定的状态,撤销偏向后恢复到未锁定01或轻量级锁定00的状态,后续的同步操作就如轻量级锁执行过程。如下图。
在这里插入图片描述

如果程序中的大多数锁总是被多个不同的线程访问,则偏向模式是多于的,这种情况禁止偏向锁优化反而会提升性能。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值