[JavaEE系列] 多线程常见的锁策略及synchronized底层工作过程

文章目录

  • 1. 目标
  • 2. 常见的锁策略
    • 2.1 乐观锁 & 悲观锁
    • 2.2 普通互斥锁 & 读写锁
    • 2.3 重量级锁 & 轻量级锁
    • 2.4 自旋锁 & 挂起等待锁
    • 公平锁 & 非公平锁
    • 2.5 可重入锁 & 不可重入锁
  • 3. synchronized底层工作原理
    • 3.1 synchronized使用的锁策略
    • 3.2 synchronized加锁工作过程
    • 3.3 synchronized锁优化操作
      • 3.3.1 锁消除
      • 3.3.2 锁粗化
  • 4. 总结
    • 4.1 要点一
    • 4.2 要点二
    • 4.3 要点三
    • 4.4 要点四
    • 4.5 要点五

1. 目标

        本篇文章主要是了解多线程中几种常见的锁策略, 为后面理解锁中更深层的知识点做准备. 再由锁策略的知识点结合之前文章中的 synchronized 关键字相关知识点来进一步理解 synchronized 底层的工作原理.

2. 常见的锁策略

        前面我们在讲线程安全问题的时候, 介绍了 synchronized 关键字, 并使用其对线程进行加锁操作. 那么加锁又是如何加的呢? 针对这个问题就引出了锁策略. 下面介绍几种常见的锁策略.

2.1 乐观锁 & 悲观锁

  • 乐观锁: 预测接下来的操作中锁冲突的概率不是很大, 这时候就会使用乐观锁.
  • 悲观锁: 预测接下来的操作中锁冲突的概率会比较大, 这时候就会使用悲观锁.

        注意: 前面介绍的 synchronized 既是乐观锁也是悲观锁, 它会针对不同场景来做出相应的变化(可以在这两种锁之间自由切换). 就比如: 当锁冲突的概率不是很大的时候, 就会以乐观锁的方式运行, 这时候往往是纯用户态执行的; 当锁冲突的概率比较大的时候, 就会以悲观锁的方式运行, 这时候往往要进入内核, 对当前线程进行挂起等待.

2.2 普通互斥锁 & 读写锁

  • 普通互斥锁: 就比如使用 synchronized 关键字, 就是一个普通的互斥锁, 两个加锁操作之间会发生竞争.
  • 读写锁: 读写锁其实是加锁操作的一个细化 — 分为"读锁"和"写锁", 并不是直接有一个锁叫做"读写锁".

        针对读写锁操作, 可以分为如下三种情况:
        (1) 情况一: 假如有两个线程, 都进行加写锁, 那么这时候两个锁操作之间就会发生竞争, 这样就和普通的互斥锁并无两样.
        (2) 情况二: 假如有两个线程, 都进行加读锁, 那么这时候两个锁操作之间是不会发生竞争的, 因为这时候多线程只进行读操作, 不涉及修改, 是线程安全的, 所以这两个锁就相当于是没加锁一样.
        (3) 情况三: 假如有两个线程, 一个线程进行加读锁, 另外一个线程进行加写锁, 由于其中一个线程涉及到修改操作, 所以这两个锁操作之间也是会发生竞争的, 那么这样也就和普通的互斥锁并无两样.


        针对读写锁, Java标准库中提供了 ReentrantReadWriteLock 类来实现读写锁. 其中 ReentrantReadWriteLock.ReadLock 类表示一个读锁; ReentrantReadWriteLock.WriteLock 类表示一个写锁, 它们的对象也都提供 lock 和 unlock 方法进行加解锁操作.


        这时候有人就会问了: 读写锁出现的三种情况中, 有两种情况是和普通的互斥锁是一样的, 既然读写锁和普通的互斥锁的重合率达到了 2/3 , 那么为什么还会出现读写锁这种东西? 直接全部都使用普通互斥锁在大多数的情况下不都是差不多的?
        虽然情况一和情况三和普通的互斥锁是一样的, 但是在实际开发中, 我们大概率使用的是多线程来进行读操作, 对读操作加普通的互斥锁会让整段代码的运行效率下降很多, 但是使用读写锁的话, 两个锁操作是不会竞争的, 运行效率会大大提高.

2.3 重量级锁 & 轻量级锁

  • 重量级锁: 主要是依赖操作系统提供的锁, 一旦使用这种锁, 就非常容易发生阻塞等待. 因为会有大量的内核态用户态的切换, 很容易引发线程的调度.
  • 轻量级锁: 主要是尽量地避免使用操作系统提供的锁, 因为操作系统提供的锁经常是用户态和内核态之间进行切换, 这样容易出现挂起等待, 所以轻量级锁尽量都是在用户态来完成功能, 尽量避免挂起等待.
            注意: 悲观锁 大多数情况下 对应的是这里的重量级锁; 而乐观锁 大多数情况下 对应的是这里的轻量级锁.

2.4 自旋锁 & 挂起等待锁

  • 自旋锁: 轻量级锁的一种具体实现, 当出现锁冲突的时候, 不会发生挂起等待, 而是会迅速再来尝试这个锁能不能被获取到. 所以说, 自旋锁也是一种乐观锁, 一旦说被释放, 就会第一时间来获取到, 当然, 如果说一直没有被释放, 那么也会消耗大量的CPU.
  • 挂起等待锁: 重量级锁的一个具体实现, 当出现锁冲突的时候, 会直接挂起等待. 所以说, 挂起等待锁也是一个悲观锁, 一旦锁被释放, 它是不能第一时间来获取到这个锁的, 但是, 在锁被其他线程占用的时候, 它是会直接放弃CPU资源的, 不会出现大量消耗CPU的情况.

公平锁 & 非公平锁

  • 公平锁: 遵循"先来后到"原则的锁就是公平锁. 一旦锁释放, 公平锁就会根据线程的先后顺序来控制某一个线程先获取到这个锁, 而实现公平锁, 需要使用到额外的数据结构来实现, 后面文章会再详细介绍.
  • 非公平锁: 不遵循"先来后到"的原则, 而是进行"机会均等"的竞争的锁就是非公平锁. 操作系统内部对于挂起等待锁就是非公平锁, 当锁释放的时候, 操作系统会对线程进行随机调度, 这时候是不会管线程的先后顺序的, 而是多个线程同时来竞争这一把锁.

2.5 可重入锁 & 不可重入锁

  • 可重入锁: 同一个线程中可以多次获取同一把锁就称为可重入锁.
  • 不可重入锁: 同一个线程中不可以多次获取同一把锁就称为可重入锁.
//第一次加锁
synchronized(func.class){
	//第二次加锁
	synchronized(func.class){
		...
	}
}

        正如上面这段代码(synchronized的基本使用在前面文章中有总结过, 具体看这里: [线程安全问题]多线程到底可能会带来哪些风险?), 在正常情况下理解: 代码中第一次加锁是能够成功的, 但是当进行第二次加锁的时候, 由于和第一次加锁加的是同一把锁, 所以在第二次尝试进行加锁操作的时候, 会发生阻塞等待, 但是又由于第二次加锁操作发生阻塞等待而导致第一次加锁操作中的那把锁是无法进行释放的, 最终就会导致死锁(关于死锁部分内容会在后面文章中单独总结, 这里暂且可以理解成相互之间在进行阻塞/死等).
        针对这个问题, 于是引出了可重入锁这个概念, 例如上面代码, 在同一个线程中, 对同一个锁反复进行加锁操作, 也不会出现死锁的现象. 可重入锁是会在内部记录这个锁是哪个线程获取到的, 当发现加锁的线程和持有锁的线程是同一个的时候, 就不会挂起等待, 而是直接获取到锁. 当然, 在内部同时会通过一个计数器来记录当前对同一个线程进行加锁操作的次数, 最后会通过这个计数器来判断该线程释放锁的时间.
        而不可重入锁则是相反, 会出现死等的现象.

3. synchronized底层工作原理

3.1 synchronized使用的锁策略

        (1) synchronize 既是乐观锁也是悲观锁(自适应锁), synchronized 会自动判断接下来的操作中锁冲突的概率, 如若冲突很大, 就会使用悲观锁; 如若冲突不是很大, 就会使用乐观锁.
        (2) synchronized 既是轻量级锁也是重量级锁(自适应锁), synchronized 会根据代码运行的实际情况来确定要在用户态和内核态之间进行切换(使用重量级锁, 也是依赖操作系统提供的锁)还是直接纯用户态来完成功能(轻量级锁).
        (3) synchronized 中使用轻量级锁的时候, 部分是基于自旋锁实现的; 使用重量级锁, 部分是基于挂起等待锁实现的.
        (4) synchronized 知识普通的互斥锁, 因为当释放锁的时候, 线程无论哪种情况都是由系统进行随机调度的, 不是读写锁.
        (5) synchronized 是非公平锁, 因为当锁释放的时候, 操作系统会对线程进行随机调度, 多个线程同时竞争同一把锁.
        (6) synchronized 是可重入锁, 因为其同一个线程中可以多次获取同一把锁就称为可重入锁.

3.2 synchronized加锁工作过程

        上面很多情况下 synchronized 都是扮演自适应锁的角色, 那么 synchronized 是如何进行自适应的呢? 由此引出了下面 synchronized 在加锁的时候经历的几个阶段(这几个过程称为是锁膨胀/锁升级的过程).

        synchronized 加锁会经历下面这四个阶段(锁竞争程度逐级递增):
        (1) 无锁(没加锁). 这一点很容易理解, 无锁适用于单线程等情况下, 使用 synchronized 进行加锁, 但是是无锁的.
        (2) 偏向锁(刚开始加锁, 未产生锁竞争). 偏向锁并不是真正意义上的加锁, 而是对这个锁做了一个标记, 在遇到其他线程来竞争之前, 它都是没加锁状态, 直到有线程来参与竞争的时候, 它才会真正加锁, 如若没有人来跟它竞争, 那么它会一直保持没加锁状态. 这样必要时才进行加锁操作, 在很多情况下会节省很多的开销.
        (2) 轻量级锁(产生锁竞争, 一般是自旋锁). 详细内容可看上面.
        (2) 重量级锁(锁竞争更激烈, 一般是挂起等待锁). 详细内容可看上面.

3.3 synchronized锁优化操作

3.3.1 锁消除

	StringBuffer stringBuffer = new StringBuffer();
	stringBuffer.append("111");
	stringBuffer.append("222");
	stringBuffer.append("333");

        在上面的这段代码中, 由于 StringBuffer 类是线程安全的类, 在这个类内部的 append 方法是加上了 synchronized 锁的, 所以上面四行代码中本质上其实就已经加了三个锁, 运行效率理论上是会非常低的, 但是由于这段代码只是单线程的, 所以JVM内部就会自动将这三个锁去掉了, 类似这样的操作就称为锁消除, 把原本有的锁给消除掉了, 消除之后会使线程运行效率变高.

3.3.2 锁粗化

        在谈锁粗化之前, 我们先来比较一下下面这两段代码:

	//代码1:
	for(int i=0;i<n;i++){
		synchronized(locker){
			n++;
		}
	}
	//代码2:
	synchronized(locker){
		for(int i=0;i<n;i++){
			n++;
		}
	}

        很容易地就看出上面两段代码的区别就是: 把锁放在for循环的外头还是for循环的里头.
        (1) 在代码1中, 把锁放在for循环的里头, 画成图像就类似于这样:
[JavaEE系列] 多线程常见的锁策略及synchronized底层工作过程_第1张图片
        这样加锁的方式会让锁的粒度比较细, 能够更好地提高线程的并发, 但是随之而来的问题就是频繁地进行加锁解锁操作, 可能会让代码的效率大大降低.
        (2) 在代码2中, 把锁放在for循环的外头, 画成图像就类似于这样:
[JavaEE系列] 多线程常见的锁策略及synchronized底层工作过程_第2张图片
        通常情况下, 代码1中锁包含的范围比较小(粒度细), 效率往往是会比较低的, 所以很可能会被JVM优化成代码2中那样锁包含的范围比较大(粒度粗), 类似这样的优化过程就称为是锁粗化. 锁粗化的目的就在于避免频繁地进行加锁解锁操作, 提高代码的效率.

4. 总结

4.1 要点一

        乐观锁会认为多个线程访问同一个共享变量的时候, 发生冲突的概率并不会很大, 不会真正地加锁, 而是尝试进行访问数据, 在访问的同时识别当前数据是否出现访问冲突; 悲观锁则认为多个线程访问同一个共享变量的时候, 发生冲突的概率是比较大的, 在每次访问之前都会进行真正的加锁.
        其中, 悲观锁的实现就是先进行加锁, 当获取到锁的时候再对数据进行操作, 如果获取不到锁的话, 就会进入阻塞等待; 乐观锁的实现则是引入一个版本号, 借助版本号来识别当前数据访问是否冲突(关于版本号的具体内容会在下一篇文章中总结).

4.2 要点二

        读写锁就是对读操作和写操作来进行加锁, 由于只有在读锁和读锁之间才不会产生竞争, 其他情况都会产生竞争, 所以读写锁最主要还是应用在频繁读且不频繁写的场景中.

4.3 要点三

        自旋锁的特点是会第一时间去获取到锁, 如果获取锁失败的话, 会马上再去尝试获取这个锁, 无限循环, 直到获取到这个锁为止. 相比于挂起等待锁, 优点在于: 没有放弃CPU资源, 一旦锁被释放, 就会再第一时间获取到这个锁, 非常高效, 这种是非常使用用在持有锁时间比较短的情况下. 缺点在于: 一旦这个锁持有的时间比较长, 由于其一直在尝试获取这个锁, 对CPU的占用是会比较高的, 可能就会浪费大量的CPU资源.

4.4 要点四

        synchronized 是可重入锁, 可重入锁就是同一个线程中多次获取同一把锁而不会导致出现死锁的情况. 简单的实现方式就是计数器会记录下线程中加锁的次数, 当发现当前加锁的线程就是持有锁的线程的时候, 计数器就会进行自增, 最后还会通过这个计数器来判断该线程释放锁的时间.

4.5 要点五

        偏向锁不是真正的加锁, 而是在锁的对象头中记录一个标记(记录的是该锁所处的线程), 如若没有其他的线程与它参与竞争, 那么就不会真正执行加锁操作, 从而降低程序的开销; 但是当有其他线程进来参与竞争, 那么就会取消偏向锁, 进入轻量级锁的状态.

你可能感兴趣的:(JavaEE初阶系列,java-ee,java,servlet)