Snipaste_20211103_232101.png

1. synchronized

1.1 为什么每个对象都可以成为一把锁

因为对象都继承了 Object,每个 Object 对象里 markOop.monitor() 都保存 ObjectMonitor 的对象,所以每个对象天生都带着一把对象监视器,即在对象头文件中存储了锁的相关信息

1.2 synchronized 的缺点

  • 效率低:锁的释放情况少,试图获得锁时不能设置超时,不能中断一个正在试图获得锁的线程
  • 不够灵活(读写锁更灵活):加锁和释放的时机单一,每个锁仅有单一的条件(某个对象),可能是不够的
  • 无法知道是否成功获取到锁

1.3 锁升级

java 的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统介入,需要在户态与核心态之间切换,这种切换会消耗大量的系统资源,因为用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。

在 java 早期版本中,synchronized 属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,挂起线程和恢复线程都需要转入内核态去完成,阻塞或唤醒一个 java 线程需要操作系统切换 CPU 状态来完成,这种状态切换需要耗费处理器时间,如果同步代码块中内容过于简单,这种切换的时间可能比用户代码执行的时间还长,时间成本相对较高,这也是为什么早期的 synchronized 效率低的原因,java6 之后,为了减少获得锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁

Snipaste_20211226_230812.png

synchronized.png

1.3.1 偏向锁

当一段同步代码一直被同一个线程多次访问,由于只有一个线程那么该线程在后续访问时便会自动获得锁,因为大多数多线程的情况下,锁不仅不存在多线程竞争,还存在锁由同一线程多次获得的情况,偏向锁就是在这种情况下出现的,它的出现是为了解决只有在一个线程执行同步时提高性能

理论落地:
在实际应用运行过程中发现,“锁总是同一个线程持有,很少发生竞争”,也就是说锁总是被第一个占用它的线程拥有,这个线程就是锁的偏向线程
那么只需要在锁第一次被拥有的时候,记录下偏向线程 ID,这样偏向线程就一直持有着锁(后续这个线程进入和退出这段加了同步锁的代码块时,不需要再次加锁和释放锁,而是直接比较对象头里面是否存储了指向当前线程的偏向锁)
如果相等表示偏向锁是偏向于当前线程的,就不需要再尝试获得锁了,直到竞争发生才释放锁,以后每次同步,检查锁的偏向线程 ID 与当前线程 ID 是否一致,如果一致直接进入同步,无需每次加锁解锁都去 CAS 更新对象头,如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高
假如不一致意味着发生了竞争,锁已经不是总是偏向于同一个线程了,这时候可能需要升级变为轻量级锁,才能保证线程间公平竞争锁,偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的

技术实现:
一个 synchronized 方法被一个线程抢到了锁时,那这个方法所在的对象就会在其所在的 Mark Word 中将偏向锁修改状态位,同时还会有占用前 54 位来存储线程指针作为标识,若该线程再次访问同一个 synchronized 方法时,该线程只需去对象头的 Mark Word 中去判断一下是否有偏向锁指向本身的 ID,无需再进入 Monitor 去竞争对象了

偏向锁在 java6 之后是默认开启的,但是启动时间有延迟,所以需要添加参数 -XX:BiasedLockingStartupDelay=0,让其在程序启动时立刻启动
开启偏向锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
关闭偏向锁(程序默认进入轻量级锁状态):-XX:-UseBiasedLocking

Snipaste_20211226_231725.png

偏向锁的撤销:
偏向锁使用一种等到竞争出现才释放锁的机制,只有当其他线程竞争锁时,持有偏向锁的原来线程才会被撤销
撤销需要等待全局安全点(该时间点上没有字节码正在执行),同时检查持有偏向锁的线程是否还在执行:

  • 第一个线程正在执行 synchronized 方法(处于同步块),它还没有执行完,其它线程来抢夺,该偏向锁会被取消掉并出现锁升级,此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁
  • 第一个线程执行完成 synchronized 方法(退出同步块),则将对象头设置成无锁状态并撤销偏向锁,重新偏向

Snipaste_20211226_232425.png

1.3.2 轻量级锁

有线程来参与锁的竞争,但是获取锁的冲突时间极短,本质就是自旋锁

轻量级锁是为了在线程近乎交替执行同步块时提高性能
主要目的:在没有多线程竞争的前提下,通过 CAS 减少重量级锁使用操作系统互斥量产生的性能消耗,说白了先自旋再阻塞
升级时机:当关闭偏向锁功能或多线程竞争偏向锁会导致偏向锁升级为轻量级锁

假如线程 A 已经拿到锁,这时线程 B 又来抢该对象的锁,由于该对象的锁已经被线程 A 拿到,当前该锁已是偏向锁了
而线程 B 在争抢时发现对象头 Mark Word 中的线程 ID 不是线程 B 自己的线程 ID (而是线程 A),那线程 B 就会进行 CAS 操作希望能获得锁,此时线程 B 操作中有两种情况:

  • 如果锁获取成功,直接替换 Mark Word 中的线程 ID 为 B 自己的 ID(A → B),重新偏向于其他线程(即将偏向锁交给其他线程,相当于当前线程"被"释放了锁),该锁会保持偏向锁状态,A 线程结束,B 线程上位
  • 如果锁获取失败,则偏向锁升级为轻量级锁,此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程B会进入自旋等待获得该轻量级锁

什么时候自旋结束:

  • 自适应,自适应意味着自旋的次数不是固定不变的,而是根据同一个锁上一次自旋的时间以及拥有锁线程的状态来决定

轻量锁与偏向锁的区别和不同:

  • 争夺轻量级锁失败时,自旋尝试抢占锁
  • 轻量级锁每次退出同步块都需要释放锁,而偏向锁是在竞争发生时才释放锁
1.3.3 重量级锁

有大量的线程参与锁的竞争,冲突性很高,会使线程进行用户态到内核态的切换

Snipaste_20211226_235020.png

1.3.4 锁的对比
优点特点
偏向锁加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距如果线程同存在锁竞争会带来额外的锁撤销的消耗
轻量级锁竞争的线程不会阻塞,提高了程序的响应速度如果始终得不到锁竞争的线程,使用自旋会消耗 CPU
重级锁线程竞争不使用自旋,不会消耗 CPU线程阻塞,响应时间缓慢

  • 偏向锁:适用于单线程适用的情况,在不存在锁竞争的时候进入同步方法/代码块则使用偏向锁
  • 轻量级锁:适用于竞争较不激烈的情况(这和乐观锁的使用范围类似),存在竞争时升级为轻量级锁,轻量级锁采用的是自旋锁,如果同步方法/代码块执行时间很短的话,采用轻量级锁虽然会占用 CPU 资源但是相对比使用重量级锁还是更高效
  • 重量级锁:适用于竞争激烈的情况,如果同步方法或代码块执行时间很长,那么使用轻量级锁自旋带来的性能消耗就比使用重量级锁更严重,这时候就需要升级为重量级锁

2. Lock

Lock 接口最常见的实现类是 ReentrantLock,通常情况下,Lock 只允许一个线程来访问这个共享资源,不过有的时候,一些特殊的实现也可允许并发访问,比如 ReadWriteLock 里面的 ReadLock
Lock 的加解锁和 synchronized 有同样的内存语义,也就是说下一个线程加锁后可以看到所有前一个线程解锁前发生的所有操作
Lock 并不是用来代替 synchronized 的,而是当使用 synchronized 不合适或不足以满足要求的时候,来提供高级功能的

2.1 主要方法

  • lock:最普通的获取锁,如果锁已被其他线程获取,则进行等待,Lock 不会像 synchronized 一样在异常时自动释放锁,因此最佳实践是,在 finally 中释放锁,以保证发生异常时锁一定被释放,lock() 方法不能被中断,这会带来很大的隐患:一旦陷入死锁 lock() 就会陷入永久等待
  • tryLock:用来尝试获取锁,如果当前锁没有被其他线程占用,则获取成功,则返回 true,否则返回 false,代表获取锁失败,相比于 lock(),这样的方法显然功能更强大了,我们可以根据是否能获取到锁来决定后续程序的行为,该方法会立即返回,即便在拿不到锁时不会一直等待
  • tryLock(long time, TimeUnit unit):超时就放弃
  • lockInterruptibly:相当于 tryLock(long time, TimeUnit unit) 把超时时间设置为无限,在等待锁的过程中,线程可以被中断
  • unlock:解锁

3. 乐观锁与悲观锁

3.1 乐观锁

在更新的时候,会对比之前修改的数据有没有被人改变过,如果没有则去修改数据,适合并发写入少,大部分都是读取的场景,不加锁能让读取性能大幅提高,一般使用 CAS 算法实现
例:原子类 和 并发容器

3.2 悲观锁

每次修改数据时,就把数据锁住,适合并发写入多的情况,适用于临界区持锁时间比较长的情况,悲观锁可以避免大量的无用自旋等消耗
例:synchronized 和 lock

3.3 互斥同步锁(悲观锁)的劣势

  • 阻塞和唤醒带来的性能劣势
  • 永久阻塞:如果持有锁的线程被永久阻塞,比如遇到了无限循环、死锁等活跃性问题,那么等待该线程释放锁的那几个线程,将永远也得不到执行
  • 优先级反转

3.4 互斥同步锁的典型情况

  • 临界区有 IO 操作
  • 临界区代码复杂或者循环量大
  • 临界区竞争非常激烈

3.5 开销对比

悲观锁的原始开销要高于乐观锁,但是特点是一劳永逸,临界区持锁时间就算越来越差,也不会对互斥锁的开销造成影响
相反,虽然乐观锁一开始的开销比悲观锁小,但是如果自旋时间很长或者不停重试,那么消耗的资源也会越来越多

4. 可重入锁与非可重入锁

可重入锁:同一个线程可以重复获取同一把锁,synchronized 和 ReentrantLock 都是可重入锁

4.1 好处

  • 避免死锁
  • 提升封装性

4.2 主要方法

  • isHeldByCurrentThread:当前锁是否被当前线程持有
  • getQueueLength:返回当前正在等待这把锁的队列长度

5. 公平锁与非公平锁

公平指的是按照线程请求的顺序,来分配锁,非公平指的是,不完全按照请求的顺序,在一定情况下,可以插队
非公平也同样不提倡“插队”行为,这里的非公平,指的是“在合适的时机”插队,而不是盲目插队
tryLock() 不遵守设定的公平的规则

5.1 非公平目的

  • 提高效率,不调用 hasQueuedPredecessors(),即不检查队列是否有在等待的线程
  • 避免唤醒带来的空档期

5.2 对比

优势劣势
公平锁各线程公平平等,每个线程在等待一段时间后,总有执行的机会更慢,吞吐量更小
非公平锁更快,吞吐量更大有可能产生线程饥饿,也就是一些线程长时间内始终得不到执行

5.3 使用 lock 加锁两次,解锁一次会遇到什么情况

public class Test {

    public static void main(String[] args) {

        Lock lock = new ReentrantLock();

        new Thread(() -> {
            lock.lock();
            try {
                System.out.println("外层");
                lock.lock();
                try {
                    System.out.println("内层");
                } catch (Exception e) {
                    e.printStackTrace();
                }
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }
        }, "a").start();

        new Thread(() -> {
            lock.lock();
            try {
            } finally {
                lock.unlock();
            }
        }, "b").start();
    }
}

如果一个线程的情况下,只解锁一次是可以的,但是多个线程同时用一把锁的情况下会造成死锁

6. 共享锁和排它锁

排他锁:又称为独占锁、独享锁
共享锁:又称为读锁,获得共享锁之后,可以查看但无法修改和删除数据,其他线程此时也可以获取到共享锁,也可以查看但无法修改和删除数据
共享锁和排它锁的典型是读写锁 ReentrantReadWriteLock,其中读锁是共享锁,写锁是独享锁

6.1 读写锁的作用

在没有读写锁之前,我们假设使用 ReentrantLock,那么虽然我们保证了线程安全,但是也浪费了一定的资源,多个读操作同时进行,并没有线程安全问题
在读的地方使用读锁,在写的地方使用写锁,灵活控制,如果没有写锁的情况下,读是无阻塞的,提高了程序的执行效率

6.2 读写锁的原则

  • 多个线程只申请读锁,都可以申请到
  • 如果有一个线程已经占用了读锁,则此时其他线程如果要申请写锁,则申请写锁的线程会一直等待释放读锁
  • 如果有一个线程已经占用了写锁,则此时其他线程如果申请写锁或者读锁,则申请的线程会一直等待释放写锁
  • 一句话总结:要么是一个或多个线程同时有读锁,要么是一个线程有写锁,但是两者不会同时出现(要么多读,要多一写)

读写锁的规则换一种思路更容易理解,读写锁只是一把锁,可以通过两种方式锁定,读锁定和写锁定,读写锁可以同时被一个或多个线程读锁定,也可以被单一线程写锁定,但是永远不能同时对这把锁进行读锁定和写锁定,这里是把“获取写锁”理解为“把读写锁进行写锁定”,相当于是换了一种思路,不过原则是不变的,就是要么是一个或多个线程同时有读锁(同时读锁定),要么是一个线程有写锁(进行写锁定),但是两者不会同时出现

6.3 读锁插队策略

公平锁非公平锁
不允许插队写锁可以随时插队
读锁仅在等待队列头结点不是想获取写锁的线程的时候可以插队

6.4 升降级策略

只能降级不能升级

6.5 适用场合

相比于 ReentrantLock 适用于一般场合,ReentrantReadWriteLock 适用于读多写少的情况,合理使用可以进一步提高并发效率

7. 自旋锁和阻塞锁

阻塞或唤醒一个 Java 线程需要操作系统切换 CPU 状态来完成,这种状态转换需要耗费处理器时间
如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长
在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失
如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁
而为了让当前线程“稍等一下”,我们需让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销,这就是自旋锁
阻塞锁和自旋锁相反,阻塞锁如果遇到没拿到锁的情况,会直接把线程阻塞,直到被唤醒

7.1 自旋锁的缺点

如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源在自旋的过程中,一直消耗 CPU,所以虽然自旋锁的起始开销低于悲观锁,但是随着自旋时间的增长,开销也是线性增长的

7.2 AtomicInteger 的实现

自旋锁的实现原理是 CAS,AtomicInteger 中调用 unsafe 进行自增操作,源码中的 do-while 循环就是一个自旋操作,如果修改过程中遇到其他线程竞争导致没修改成功,就在 while 里死循环,直至修改成功

7.3 自旋锁的适用场景

自旋锁一般用于多核的服务器,在并发度不是特别高的情况下,比阻塞锁的效率高,另外,自旋锁适用于临界区比较短小的情况,否则如果临界区很大(线程一旦拿到锁,很久以后才会释放),那也是不合适的

8. 可中断锁

在 Java 中,synchronized 是不可中断锁,而 Lock 是可中断锁,因为 tryLock(time) 和 lockInterruptibly() 都能响应中断
如果某一线程 A 正在执行锁中的代码,另一线程 B 正在等待获取该锁,可能由于等待时间过长,线程 B 不想等待了,想先处理其他事情,我们可以中断它,这种就是可中断锁

9. 如何优化锁和提高并发性能

  • 缩小同步代码块
  • 尽量不要锁住方法
  • 减少请求锁的次数
  • 避免人为制造“热点”
  • 锁中不要再包含锁
  • 选择合适的锁及合适的工具类

Q.E.D.


盛年不重来,一日难再晨。