Java锁主要是为了解决线程安全问题,当多个线程共享同一个变量时可能会出现同时修改变量的情况,这样会导致最终计算结果错误。未解决该问题,Java提供了各种锁来确保数据能够被正常修改和访问。最常用的比如synchronized。
一、互斥同步
互斥同步也称为堵塞同步,属于一种悲观的并发策略,从概念上说属于悲观锁,悲观锁总认为只要不加锁就会出现问题。Java中,synchronized关键字和Lock的实现类都是悲观锁。
Java中,最基本的互斥同步手段就是synchronized关键字,该关键字经过编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象。如果Java程序中synchronize明确指定了对象参数,那就是这个对象的reference;如果没有明确指定,那就根据synchronized修饰是实例方法还是类方法,去取对应的对象实例或Class对象来作为锁对象。
Java另外增加了重入锁(ReentrantLock)(也叫递归锁),在java.util.concurrent包中。ReentrantLock与synchronized很相似,都具备线程重入特性,不过ReentrantLock比synchronized增加了一些高级功能:
- 等待可中断:持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情,可中断特性对处理执行时间非常长的同步块很有帮助。
- 公平锁:指多个线程等待同一个锁是,必须按照申请锁的时间顺序来依次获得锁;而非公平锁不保证这一点,在锁释放时,任何一个等待锁的线程都有机会获得锁。synchronized中的锁是非公平的,ReentrantLock默认情况下也是非公平的。
- 绑定多个条件:指一个ReentrantLock对象可以同时绑定多个Condition对象,而在synchronized中,锁对象的wait()和notify()或notifyAll()方法可以实现一个隐含条件,如果要和多个条件关联时,就不得不额外添加一个锁,而ReentrantLock则无须这样做,只需要多次调用newCondition()方法即可。
二、非堵塞同步
非堵塞同步是基于冲突检测的乐观并发策略,从概念上说属于乐观锁,乐观锁认为自己使用数据时不会有其它线程同时访问数据,因此先进行操作,如果没有其它线程竞争共享数据,则操作成功;如果出现竞争的情况,则根据情况进行其它操作(最常见补偿措施就是不断地重试,直到成功为止)。
乐观锁具体实现是CAS(Compare-and-Swap)指令。
CAS指令需要三个操作数:
- 变量内存地址 V;
- 旧的预期值 A;
- 将要写入的新值 B;
当且仅当V符合旧预期值A时,处理器用新值B更新V的值,否则就不执行更新,但是不管是否更新了V的值,都会返回V的旧值,上述处理过程是一个原子操作。
Java中java.util.concurrent包中的原子类,就是通过CAS来实现了乐观锁。比如AtomicInteger。
CAS看起来很美,但是也存在如下问题:
- ABA问题:如果一个变量V初次读取时是A值,并在准备赋值时检查它仍然是A值,但这段时间它的值可能改成了B,后来又被改回A,则CAS操作会误认为它从来没有被改变过。java.util.concurrent包为解决该问题,提供了一个带有标记的原子引用类“AtomicStampedReference”,它通过控制变量值版本来保证CAS正确性。不过大部分情况ABA问题不会影响程序并发性,如果需要解决ABA问题,则改用传统互斥同步可能比原子类更高效。
- 循环时间不可控:从AtomicInteger的源码来看如果长时间没有更新成功,会导致一直循环,占用CPU大量时间片。
三、锁优化
1、自旋锁&&自适应自旋锁
前面提到互斥同步,该手段对性能最大影响是堵塞的实现,挂机和恢复线程操作都需要从用户态转入内核态中完成,这种转换要耗费CPU时间。在许多应用上,共享数据锁定状态只会持续很短一段时间,很可能这个时间比线程转换时间还少,所以这种情况加锁不划算。
如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的线程“稍等一会儿”,但不放弃处理器执行时间,看看持有锁的线程是否很快释放锁。为了让线程等待,只需让线程执行一个循环(自旋),这项技术就是所谓的自旋锁。
自旋锁原理是CAS,但是自旋锁缺点是可能过多占用CPU时间,如果锁被占用时间很长,自旋的线程就白白耗费处理器资源,反而带来性能浪费。因此自旋等待时间有一定限度,如果超过默认自旋次数则会使用传统方式挂起线程。自旋次数默认10次。
JDK1.6中引入自适应自旋锁。自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。另一方面,如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
2、轻量级锁&&重量级锁
轻量级锁是JDK1.6中加入的新型锁机制,“轻量级”是相对于使用操作系统互斥量来实现传统锁而言的,因此传统的锁机制被称为重量级锁。轻量级锁并不是用来代替重量级锁的,本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
要理解轻量级锁,必须了解Java对象头,以HotSpot虚拟机对象头为例,对象头有两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码、GC分代年龄等,称为“Mark Word”,他是实现轻量级锁和偏向锁的关键;另一部分用于存储指向方法区对象类型数据的指针,如果是数组对象,还会有一个额外部分用于存储数组长度。
然后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。如果这个更新动作成功,则这个线程就拥有了该对象的锁,并且对象Mark Word标志位将转变为“00”,即表示此对象处于轻量级锁定状态。 在代码进入同步块时,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝。
如果这个更新操作失败了,虚拟机首先会检查对象Mark Word是否指向当前线程栈帧,如果是就说明当前线程已经拥有这个对象锁,可以直接进入同步块继续指向,否则说明这个锁对象已经被其他线程抢占。如果有两条以上的线程争用同一个锁,那轻量级锁不再有效,要膨胀为重量级锁,所标志为“10”,后面等待锁的线程就进入堵塞状态。
轻量级锁能提升程序同步性能依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据。如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量的开销,但如果存在锁竞争,处理互斥量开销外,还额外发生CAS操作,因此在有竞争的情况下,轻量级锁会比传统重量级锁更慢。
3、偏向锁
偏向锁也是JDK1.6中引入的一项锁优化,如果说轻量级锁是在无竞争情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不做了。
假设当前虚拟机启用了偏向锁,当锁对象第一次被线程获取时,虚拟机就会把对象头的标志位设为“01”,即偏向模式。同时使用CAS操作把获取到这个锁的线程ID记录在对象的Mark Word中,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,都可以不再进行任何同步操作。
当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。根据所对象目前是否处于锁定状态,撤销偏向后恢复到未锁定(标志位“01”)或轻量级锁定(标志位“00”),后续同步操作如上面介绍的轻量级锁那样执行。
总结:Java中的锁机制,主要有两种思想,一种是悲观锁,一种是乐观锁。其中悲观锁里边又细分为各种锁来应对不同场景。乐观锁主要就是通过CAS来达到目的。
Java锁优化,主要是在CAS的基础上执行不同的策略。
自旋锁跟非自旋锁主要针对前面线程获取锁了,后面线程应该执行哪种策略的场景,是自旋等待,还是直接堵塞。
轻量级锁、重量级锁、偏向锁针对的是当前要锁的对象,根据当前被锁对象状态决定用哪种锁。