锁分类

偏向锁/轻量级锁/重量级锁

这三种锁特指 synchronized 锁的状态,通过在对象头中的 mark word 来表明锁的状态。

偏向锁

这把锁不存在竞争的时候就没有必要去上锁,只需要打一个标记。当一个对象被初始化的时候,还没有线程获取它的锁的时候,他就是可偏向的,当第一个线程来访问的时候,就将该线程记录下来,当这个线程再次访问的时候就可以直接获得这个锁,开销最小,性能最好。

轻量级锁

在很多情况下,synchronized 中的代码是被多个线程交替执行的,而不是同时执行的,也就是说并不存在实际的竞争,或者是只有短时间的锁竞争,用 CAS 就可以解决,轻量级锁是指当锁原来是偏向锁的时候,被另一个线程访问,说明存在竞争,那么偏向锁就会升级为轻量级锁,线程会通过自旋的形式尝试获取锁,而不会陷入阻塞。

重量级锁

重量级锁是互斥锁,它是利用操作系统的同步机制实现的,所以开销相对比较大。当多个线程直接有实际竞争,且锁竞争时间长的时候,轻量级锁不能满足需求,锁就会膨胀为重量级锁。
锁升级:
synchronized原理深入 - 图1

可重入锁/非可重入锁

可重入锁指的是线程当前已经持有这把锁了,能在不释放这把锁的情况下,再次获取这把锁。同理,不可重入锁指的是虽然线程当前持有了这把锁,但是如果想再次获取这把锁,也必须要先释放锁后才能再次尝试获取。

公平锁/非公平锁

公平锁的公平的含义在于如果线程现在拿不到这把锁,那么线程就都会进入等待,开始排队,在等待队列里等待时间长的线程会优先拿到这把锁,有先来先得的意思。而非公平锁就不那么“完美”了,它会在一定情况下,忽略掉已经在排队的线程,发生插队现象。

悲观锁/乐观锁

悲观锁

悲观锁的概念是在获取资源之前,必须先拿到锁,以便达到“独占”的状态,当前线程在操作资源的时候,其他线程由于不能拿到锁,所以其他线程不能来影响我。并且这个资源也是锁定的。
悲观锁适合用于并发写入多、临界区代码复杂、竞争激烈等场景,这种场景下悲观锁可以避免大量的无用的反复尝试等消耗。

乐观锁

而乐观锁恰恰相反,它并不要求在获取资源前拿到锁,也不会锁住资源;相反,乐观锁利用 CAS 理念,在不独占资源的情况下,完成了对资源的修改。为保证数据的准确性,在更新之前,会去对比在修改数据期间,数据有没有被其他线程修改过,如果未被修改过那么就可以放心修改数据,如果是被别的线程修改过那么可以选择报错、重试这种策略。
乐观锁适用于大部分是读取,少部分是修改的场景,也适合虽然读写都很多,但是并发并不激烈的场景。在这些场景下,乐观锁不加锁的特点能让性能大幅提高。

Synchronized关键字

多线程加锁时的原子性,有序性和可见性

synchronized如何保证并发安全的

一旦说某个线程加了一把锁之后,就会保证,其他的线程没法去读取和修改这个变量的值了,同一时间,只有一个线程可以读这个数据以及修改这个数据,别的线程都会卡在尝试获取锁那儿

原子性

一个对象主要包含对象头和实例变量两个部分,加锁主要是在对象头中做的动作,对象头包含了两块东西,一个是Mark Word(包含hashCode、锁数据、GC数据,等等),另一个是Class Metadata Address(包含了指向类的元数据的指针)。
在Mark Word里就有一个指针,是指向了这个对象实例关联的monitor的地址,这个monitor是c++实现的,不是java实现的。这个monitor实际上是c++实现的一个ObjectMonitor对象,里面包含了一个_owner指针,指向了持有锁的线程。
ObjectMonitor 结构
_owner 指向持有ObjectMonitor对象的线程地址。
_WaitSet 存放调用wait方法,而进入等待状态的线程的队列。
_EntryList 这里是等待锁block状态的线程的队列。
_recursions 锁的重入次数。
_count 线程获取锁的次数。
多线程加锁/释放锁:
当多个线程来临时,会将这些线程放入entryList集合中等待获取机会尝试加锁,如果某个线程加锁成功了,就将owner这个位置指向当前的线程然后对_count计数器累加1次,同时对count的操作在jdk1.6之后被优化成使用cas的方式去修改值。
释放锁的时候,先是对_count计数器递减1,如果为0了就会设置_owner为null,不再指向自己,代表自己彻底释放锁
monitor对象很重要,synchronized的ObjectMonitor的地位就跟ReentrantLock里的AQS是差不多的。
image.png

可见性&有序性

  1. int b = 0;
  2. int c = 0;
  3. synchronized(this) { -> monitorenter
  4. Load内存屏障
  5. Acquire内存屏障 LoadLoad+LoadStore
  6. int a = b;
  7. c = 1; => synchronized代码块里面还是可能会发生指令重排
  8. Release内存屏障 StoreLoad+StoreStore
  9. } -> monitorexit
  10. Store内存屏障

针对可见性而言,在monitorenter的时候会去加Load内存屏障让线程把自己在同步代码块里修改的变量的值都执行refresh的操作,把别的处理器修改过的最新值加载到自己高速缓存里来;当monitorexit的时候在后面加上了Store内存屏障,执行flush处理器缓存的操作,从写缓冲器中刷到高速缓存(或者主内存)里去。这样就保证了对别的线程的可见性。
同时,synchronized包裹的代码块会被加上Acquire和Release屏障,在这两个屏障包裹下的代码块是不允许和synchronized外部的代码进行指令重排的,但是它内部是有可能指令重排的。因此synchronized在一定程度上实现代码的有序性。

JVM对锁synchronized的优化

锁消除

JIT经过逃逸分析(分析对象的分配,如果一个对象被分析出来一定逃不出这个这个方法,那就把堆分配转换成栈分配)之后,如果发现某些对象在运行过程中是不可能被别的线程访问到时那么这个对象就是线程安全的,并且会去消除这个对象的锁也就是编译的时候就不用加入monitorenter和monitorexit的指令。
例如:StringBuffer的append方法,源码中append方法是被sychronized修饰的,大多数情况下这个对象只在线程内部使用,那么编译器就会做出优化将这个sychronized消除掉。

锁粗化

  1. public void lockCoarsening() {
  2. synchronized (this) {
  3. //do something
  4. }
  5. synchronized (this) {
  6. //do something
  7. }
  8. synchronized (this) {
  9. //do something
  10. }
  11. }

JIT在动态编译的时候,遇到这种加锁情况时,就会把同步区域扩大,也就是在最开始加锁一次到最后一个同步方法执行完再解锁,就会把中间无意义的加锁、释放锁的操作消除掉
特例:
不要再循环代码块中使用,如果在循环代码块中也就意味着直到循环完了才会释放锁,别的线程只能等待性能很差。
jvm中默认是开启的,通过-XX:-EliminateLocks可以关闭。

偏向锁

monitorenter和monitorexit的指令都是通过cas去加锁和释放锁,开销较大。因此如果发现大概率只有这一个线程会访问这个临界区资源的时候,那么就会给这个锁维护一个偏好(Bias’[ˈbaɪəs]’),后面加锁释放锁都是基于Bias执行。
如果发现别的线程来抢占锁了,就要收回专门给之前线程的那个Bias。

轻量级锁

当偏向锁加锁失败了,就是因为不同线程竞争锁太频繁了。这个时候就采用轻量级锁的方式加锁,在对象头MardWord有一个轻量级指针,尝试指向当前抢占的线程,然后去monitor中的owner去看一下是不是当前线程,如果是的话就加锁成功,否则就膨胀为重量级锁

自适应自旋锁(重量级锁优化)

image.png
当线程A持有锁,线程B过来强占锁。
此时线程B创建好线程之后会进入一个Runnable的状态,这个状态下这个线程的上下文信息都已经被收集好了,此时抢占不到锁就要将线程B从Runnable状态转为非Runnable状态(Blocked、Waiting、Timed_Waiting),转换的时候就要做上下文切换的操作,这个操作需要将相应线程的上下文信息(包括cpu的寄存器和程序计数器在某一时间点的内容等)保存下来,当线程B回到Runnable状态的时候再加载出来。这种上下文切换的代价相对较高。
因此,jvm让cpu自旋一段时间不让它立马对这个线程做状态改变,过段时间实在是拿不到这个锁了再把线程切换到非Runnable的状态。
自旋锁的升级版,防止自旋锁长时间去循环获取锁浪费cpu资源。自适应自旋锁的自旋时间不固定,根据最近自旋尝试的成功率、失败率,以及当前锁的拥有者的状态等多种因素来共同决定。如果最近尝试自旋获得锁成功了,那么就认为下一次还能获得成功,就会让这个时间等待的更长一点。如果第一次自旋锁都失败,下一次将跳过这个自旋。