深入理解Java里的各种锁(上)
不知道你有没有被Java里各种锁搞晕过,
轻量级锁 重量级锁 公平锁 非公平锁 lock 锁,synchronized锁 都有什么区别呢? 先看图再一个一个说:
1、悲观锁 VS 乐观锁
悲观锁:对于同一个数据的并发操作,悲观锁认为我在修改数据的时候肯定也会有其他线程进来修改数据,所以我在修改数据的时候需要加上一把锁不让其他线程进来,这种思路的实现的锁都是悲观锁;Java中,synchronized 和 ReentrantLock 都是悲观锁。
乐观锁:对于同一个数据的并发操作,乐观锁认为在修改数据的时候不会有别的线程也来修改这个数据,所以不会添加锁来限制不让其他线程进入,只有在更新数据的时候判断之前有没有线程更新了这个数据,如果这个数据没有被更新那么当前线程就有更新数据的权利,如果被更新了,则重试。
根据这张图的描绘可以得出一个结论:
悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。
乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。
乐观锁在Java中也被称为无锁编程,jdk 一个典型的实现就是 CAS 。
CAS 是整个 J.U.C 工具包实现的基石。
需要再了解一下CAS 的朋友戳这里:CAS 必知必会
悲观锁的具体实现有 synchronized和 ReentrantLock 等,后文会提到。
2、自旋锁 VS 适应性自旋锁
在操作系统中 阻塞 或 唤醒 一个Java线程需要操作系统 切换CPU状态 来完成,这种状态转换需要 耗费处理器时间 。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。
自旋锁:在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。如果是多核CPU 可以让多个线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁。
而为了让当前线程“稍等一下”,我们需让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。这就是自旋锁。
自旋锁本身是有缺点的,它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)没有成功获得锁,就应当挂起线程。
自旋锁在JDK1.4.2中引入,使用-XX: UseSpinning来开启。JDK 6中变为默认开启,并且引入了自适应的自旋锁(适应性自旋锁)。
适应性自旋锁:自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
3、偏向锁 VS 轻量级锁 VS 重量级锁
这三种锁是 synchronized 关键字的三种 表现状态,弄懂这三种锁状态前先看看什么是 Java 对象头 和 Monitor 。
对象头主要由 Mark Word 和 实例数据两部分组成,这里要着重描述一下
Mark Word 的作用,它与锁状态息息相关。首先看两张图
Mark Word:主要由五部分组成
hashcode
线程ID(线程是否获取到了锁)
Epoch(保存偏向时间戳)
对象分代年龄(该对象在堆空间经历了几次垃圾回收)
是否是偏向锁 (0 不是,1是)
锁标志位 (00 轻量级锁 10 重量级锁 01 偏向锁)
Monitor
Monitor可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个Java对象就有一把看不见的锁,称为内部锁或者Monitor锁。
synchronized通过Monitor来实现线程同步,Monitor是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的线程同步。
方法上加了synchronized 关键字以后,对象必须拿到 Monitor 才算获取到锁,如果没有拿到锁就会进入到一个 synchronizedQreue 队列里阻塞。
之前在自旋锁中提到,“阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长”。
这种方式就是synchronized最初实现同步的方式,这就是JDK 6之前synchronized效率低的原因。这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”,JDK 6中为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。
所以目前锁一共有3种状态,级别从低到高依次是:偏向锁、轻量级锁和重量级锁。锁状态只能升级不能降级。
偏向锁
偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。当一个线程访问同步代码块并获取锁时,会在Mark Word里存储锁偏向的线程ID。在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。偏向锁在JDK 6及以后的JVM里是默认启用的。可以通过JVM参数关闭偏向锁:
-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态。
轻量级锁
是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,然后拷贝对象头中的Mark Word复制到锁记录中。
拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象的Mark Word。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。
如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。
若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。
重量级锁
升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。
偏向锁通过对比Mark Word解决加锁问题,避免执行CAS操作。而轻量级锁是通过用CAS操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。重量级锁是将除了拥有锁的线程以外的线程都阻塞。
以上就是关于 synchronized的锁状态变化分析~