Java 多线程八股文 | 常见的锁策略、synchronized中的锁优化机制

一、常见的锁策略

锁策略,和普通程序猿基本没啥关系,和 "实现锁” 的人才有关系的
这里所提到的锁策略,和 Java 本身没关系,适用于所有和 “锁” 相关的情况

1、乐观锁 vs 悲观锁

处理锁冲突的态度(原因)

悲观锁:预期锁冲突的概率很高
乐观锁:预期锁冲突的概率很低

乐观的态度:认为,下一波疫情即使来了,但是菜应该是能买到 (根据前两波疫情的经验),就不必专门做特殊的准备

悲观的态度:认为,下一波疫情来了之后,可能就买不到菜,于是换一个大的冰箱,去超市定期屯一些米面油肉+方便面+矿泉水+常用药…… [要做的事情更多,付出更多的成本和待见]

悲观锁,做的工作更多,付出的成本更多,更低效
乐观锁,做的工作更少,付出的成本更低,更高效


2、读写锁 vs 普通的互斥锁

对于普通的互斥锁,只有两个操作,加锁和解锁
只要两个线程针对同一个对象加锁,就会产生互斥

对于读写锁来说,分成了三个操作:

  1. 加读锁 – 如果代码只是进行读操作,就加读锁

  2. 加写锁 – 如果代码中进行了修改操作,就加写锁

  3. 解锁

针对读锁和读锁之间,是不存在互斥关系的。读锁和写锁之间,写锁和写锁之间,才需要互斥
多线程同时读同一个变量,不会有线程安全问题!
而且在很多场景中,都是读操作多,写操作少 (数据库索引)


3、重量级锁 vs 轻量级锁

处理锁冲突的结果

和上面的悲观乐观有一定重叠

重量级锁,就是做了更多的事情,开销更大
轻量级锁,做的事情更少,开销更小

也可以认为,通常情况下,悲观锁一般都是重量级锁,乐观锁一般都是轻量级锁 (不绝对)

在使用的锁中,如果锁是基于内核的一些功能来实现的 (比如调用了操作系统提供的 mutex接口),此时一般认为这是重量级锁 (操作系统的锁会在内核中做很多的事情,比如让线程阻塞等待…)
如果锁是纯用户态实现的,此时一般认为这是轻量级锁 (用户态的代码更可控,也更高效)


4、挂起等待锁 vs 自旋锁

挂起等待锁,往往就是通过内核的一些机制来实现的,往往较重,[重量级锁的一种典型实现]

自旋锁,往往就是通过用户态代码来实现的,往往较轻, [轻量级锁的一种典型实现]


5、公平锁 vs 非公平锁

公平锁: 多个线程在等待一把锁的时候,谁是先来的,谁就能先获取到这个锁,(遵守先来后到)
非公平锁: 多个线程在等待—把锁的时候,不遵守先来后到,(每个等待的线程获取到锁的概率都是均等的)

注意:此处约定的是,遵守先来后到,才是公平

对于操作系统来说,本身线程之间的调度就是随机的 (机会均等的),操作系统提供的 mutex这个锁,就是属于非公平锁
–> 考虑到相同优先级的情况,实际开发中很少会手动修改线程的优先级,(改了之后在宏观上的体会并不明显)

要想实现公平锁,反而要付出更多的代价,(得整个队列,来把这些参与竞争的线程给排一排先来后到


6、可重入锁 vs 不可重入锁

一个线程,针对一把锁,咔咔连续加锁两次,如果会死锁,就是不可重入锁;如果不会死锁,就是可重入锁


7、synchronized 特性

1.既是—个乐观锁,也是一个悲观锁 (根据锁竞争的激烈程度,自适应)

2.不是读写锁只是一个普通互斥锁

3.既是一个轻量级锁,也是一个重量级锁 (根据锁竞争的激烈程度,自适应)

4.轻量级锁的部分基于自旋锁来实现,重量级的部分基于挂起等待锁来实现

5.非公平锁

6.可重入锁


二、CAS

1、什么是 CAS

CAS:全称 Compare and swap,字面意思:”比较并交换“,一个 CAS 涉及到以下操作:

我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。

  1. 比较 A 与 V 是否相等。(比较)

  2. 如果比较相等,将 B 写入 V。(交换)

  3. 返回操作是否成功

CAS 伪代码:

下面写的代码不是原子的, 真实的 CAS 是一个原子的硬件指令完成的. 这个伪代码只是辅助理解
CAS 的工作流程.

boolean CAS(address, expectValue, swapValue) {
	if (&address == expectedValue) {
		&address = swapValue;
		return true;
	}
	return false;
}

Java 多线程八股文 | 常见的锁策略、synchronized中的锁优化机制

此处所谓的 CAS 指的是,CPU 提供了一个单独的 CAS 指令,通过这一条指令,就完成上述伪代码描述的过程

如果上述过程都是这 “—条指令" 就干完了,
就相当于这是原子的了 (CPU上面执行的指令就是—条—条执行的…指令已经是不可分割的最小单位)
此时线程就安全了

CAS最大的意义,就是让我们写这种多线程安全的代码,提供了一个新的思路和方向 (就和锁不一样了)

很多功能,既可以是硬件实现,也可以是软件实现
就像刚才这段比较交换逻辑,这就相当于硬件直接实现出来了,通过这一条指令,封装好,直接让咱们用了


2、CAS 有哪些应用

2.1、基于CAS能够实现"原子类"

Java 标准库中提供了一组原子类,针对所常用多一些 int, long, int array… 进行了封装,可以基于 CAS 的方式进行修改,并且线程安

java.util.concurrent.atomic 包,里面的类都是基于这种方式来实现的
典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作.

public class TestDemo {
    public static void main(String[] args) throws InterruptedException {
       AtomicInteger num = new AtomicInteger(0); // 创建一个整数 值是 0
        // 此方法相当于 num++
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                num.getAndIncrement();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                num.getAndIncrement();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        // 通过 get 方法得到原子类 内部的数值
        System.out.println(num.get());
    }
}

运行结果:100000

这个代码里面不存在线程安全问题,基于 CAS 实现的 ++ 操作
这里面就可以保证既能够线程安全,又能够比 synchronized 高效,``synchronized会涉及到锁的竞争,两个线程要相互等待CAS` 不涉及到线程阻塞等待

方法:

num.incrementAndGet(); // ++num
num.decrementAndGet(); // --num
num.getAndIncrement(); // num++;
num.getAndDecrement(); // num--;
num.getAndAdd(10); // += 10

伪代码实现:

class AtomicInteger {
	private int value;
	
    public int getAndIncrement() {
		int oldValue = value;
		while ( CAS(value, oldValue, oldValue+1) != true) {
			oldValue = value;
		}
		return oldValue;
	}
}

Java 多线程八股文 | 常见的锁策略、synchronized中的锁优化机制
Java 多线程八股文 | 常见的锁策略、synchronized中的锁优化机制

假设两个线程同时调用 getAndIncrement

  1. 两个线程都读取 value 的值到 oldValue 中. (oldValue 是一个局部变量, 在栈上. 每个线程有自己的栈)
  2. 线程1 先执行 CAS 操作. 由于 oldValue 和 value 的值相同, 直接进行对 value 赋值
  3. 线程2 再执行 CAS 操作, 第一次 CAS 的时候发现 oldValue 和 value 不相等, 不能进行赋值. 因此需要进入循环.
    在循环里重新读取 value 的值赋给 oldValue
  4. 线程2 接下来第二次执行 CAS, 此时 oldValue 和 value 相同, 于是直接执行赋值操作
  5. 线程1 和 线程2 返回各自的 oldValue 的值即可

通过形如上述代码就可以实现一个原子类,不需要使用重量级锁,就可以高效的完成多线程的自增操作
本来 check and set 这样的操作在代码角度不是原子的,但是在硬件层面上可以让一条指令完成这个操作, 也就变成原子的了


2.2、基于CAS能够实现"自旋锁"

基于 CAS 实现更灵活的锁,获取到更多的控制权

自旋锁伪代码 :

public class SpinLock {
    private Thread owner = null; // 记录下当前锁被哪个线程持有了,为 null 表示当前未加锁
    
    public void lock(){
    // 通过 CAS 看当前锁是否被某个线程持有.
    // 如果这个锁已经被别的线程持有, 那么就自旋等待.
    // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
        while(!CAS(this.owner, null, Thread.currentThread())){
        }
    }
    
    public void unlock (){
        this.owner = null;
    }
}

和刚才的原子类类似,也是通过—个循环来实现的,循环里面调用CAS

CAS 会比较当前的 owner 值是否是 null
如果是 null 就改成当前线程,意思就是当前线程拿到了锁;如果不是 null,就返回false,进入下次循环
下次循环仍然是进行CAS操作

如果当前这个锁**一直被别人持有,**当前尝试加锁的线程就会在这个 while 的地方快速反复的进行循环 => 自旋 (忙等)

自旋锁是一个轻量级锁,也可以视为是一个乐观锁
当前这把锁虽然没能立即拿到,预期很快就能拿到 (假设锁冲突不激烈)
短暂的自旋几次,浪费点CPU,问题都不大,好处就是只要这边锁─释放,就能立即的拿到锁

例如:滑稽老铁追女神,女神说滚,滑稽滚了,过一会儿又问,这样的锲而不舍的过程,就相当于"自旋”的过程,也就是"忙等"的过程

好处:一旦女神分手了,心里处在空虚的情况下,就容易趁虚而入

坏处:浪费大量的时间精力 (不如多看看书学习学习)
如果滑稽老哥比较乐观,已经洞察到了女生的感情即将发生危机,短时间付出这些成本也是值得的
如果要是情况比较悲观的话,显然自旋锁就不合适


3、CAS 的 ABA 问题

3.1、ABA 问题

CAS 中的关键,是先比较,再交换
比较其实是在比较当前值和旧值是不是相同,把这两个值相同视为是中间没有发生过改变

但是这里的结论存在漏洞
当前值和旧值相同可能是中间确实没改变过,也有可能变了,但是又变回来了

假设存在两个线程 t1 和 t2,有一个共享变量 num,初始值为 A
接下来, 线程 t1 想使用 CAS 把 num 值改成 Z, 那么就需要

  1. 先读取 num 的值,记录到 oldNum 变量中

  2. 使用 CAS 判定当前 num 的值是否为 A, 如果为 A, 就修改成 Z

但是, 在 t1 执行这两个操作之间, t2 线程可能把 num 的值从 A 改成了 B, 又从 B 改成了 A

线程 t1 的 CAS 是期望 num 不变就修改。但是 num 的值已经被 t2 给改了,只不过又改成 A 了,这
个时候 t1 究竟是否要更新 num 的值为 Z 呢?

到这一步, t1 线程无法区分当前这个变量始终是 A, 还是经历了一个变化过程

这样的漏洞,在大多数情况下,其实没啥影响,但是,极端情况下也会引起 bug

这就好比,我们买一个手机,无法判定这个手机是刚出厂的新手机,还是别人用旧了,又翻新过的手

举—个典型的例子,ABA问题产生的bug:

假设 滑稽老哥 有 100 存款. 滑稽想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行 -50 操作
我们期望一个线程执行 -50 成功, 另一个线程 -50 失败.
如果使用 CAS 的方式来完成这个扣款过程就可能出现问题.
正常的过程:

  1. 存款 100. 线程1 获取到当前存款值为 100, 期望更新为 50; 线程2 获取到当前存款值为 100, 期
    望更新为 50.

  2. 线程1 执行扣款成功, 存款被改成 50. 线程2 阻塞等待中.

  3. 轮到线程2 执行了, 发现当前存款为 50, 和之前读到的 100 不相同, 执行失败.

异常的过程:

  1. 存款 100. 线程1 获取到当前存款值为 100, 期望更新为 50; 线程2 获取到当前存款值为 100, 期
    望更新为 50.

  2. 线程1 执行扣款成功, 存款被改成 50. 线程2 阻塞等待中.

  1. 在线程2 执行之前, 滑稽的朋友正好给滑稽转账 50, 账户余额变成 100 !

  2. 轮到线程2 执行了, 发现当前存款为 100, 和之前读到的 100 相同, 再次执行扣款操作

这个时候, 扣款操作被执行了两次!!! 都是 ABA 问题搞的鬼

int oldValue = value; //读取旧值
CAS(&value, oldValue, oldValue - 50)

当按下取款的操作的时候,机器卡了一下,滑稽多按了—下取款~
这就相当于,一次取钱操作,执行了两遍,(两个线程,并发的去执行这个取钱操作),咱们的预期效果应该是只能取成功一次!(希望取走50,账户还剩50)

假设在取款的一瞬间,滑稽的朋友给他转了50此时就会触发ABA问题

Java 多线程八股文 | 常见的锁策略、synchronized中的锁优化机制

卡了和转了50,这两次巧合导致了一个存在 BUG 的 ABA 问题 (极端场景的问题)
哪怕这样的 bug 出现概率是 0.01%,咱们也需要处理!!!
一个互联网产品每天接收的请求,处理的用户量可能是非常大的!!


3.2、解决方案

给要修改的值, 引入版本号。在 CAS 比较数据当前值和旧值的同时,也要比较版本号是否符合预期

这个版本号只能变大,不能变小,修改变量的时候,比较就不是比较变量本身了,而是比较版本号了

这里不一定非得用“版本号",也可以用“时间戳"

  • CAS 操作在读取旧值的同时,也要读取版本号
  • 真正修改的时候
    • 如果当前版本号和读到的版本号相同,则修改数据,并把版本号 + 1.
    • 如果当前版本号高于读到的版本号,就操作失败 (认为数据已经被修改过了

这就好比, 判定这个手机是否是翻新机, 那么就需要收集每个手机的数据, 第一次挂在电商网站上的
手机记为版本1, 以后每次这个手机出现在电商网站上, 就把版本号进行递增. 这样如果买家不在意
这是翻新机, 就买. 如果买家在意, 就可以直接略过

对比理解上面的转账例子:

假设 滑稽老哥 有 100 存款. 滑稽想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行 -50操作.
我们期望一个线程执行 -50 成功, 另一个线程 -50 失败.
为了解决 ABA 问题, 给余额搭配一个版本号, 初始设为 1.

  1. 存款 100. 线程1 获取到 存款值为 100, 版本号为 1, 期望更新为 50; 线程2 获取到存款值为 100,
    版本号为 1, 期望更新为 50.

  2. 线程1 执行扣款成功, 存款被改成 50, 版本号改为2. 线程2 阻塞等待中.

  3. 在线程2 执行之前, 滑稽的朋友正好给滑稽转账 50, 账户余额变成 100, 版本号变成3.

  4. 轮到线程2 执行了, 发现当前存款为 100, 和之前读到的 100 相同, 但是当前版本号为 3, 之前读
    到的版本号为 1, 版本小于当前版本, 认为操作失败

Java 多线程八股文 | 常见的锁策略、synchronized中的锁优化机制

此处就要求,每次针对余额进行修改,都让版本+1
每次修改之前,也要先对比版本看看旧值和当前值是否一致

当引入版本号之后,t2再尝试进行这里的比较版本操作,就发现版本的旧值和当前值并不匹配.
因此就放弃进行修改
如果直接拿变量本身进行判定,因为变量的值有加有减, 就容易出现 ABA 的情况,现在是拿版本号来进行判定,要求版本号只能增加, 这个时候就不会有ABA问题了

这种基于版本号的方式来进行多线程数据的控制,也是一种乐观锁的典型实现

  1. 数据库里
  2. 版本管理工具 (SVN) 通过版本号来进行多人开发的协同

在 Java 标准库中提供了 AtomicStampedReference<E> 类. 这个类可以对某个类进行包装, 在内部就提
供了上面描述的版本管理功能.

相关面试题:

  1. 讲解下你自己理解的 CAS 机
    全称 Compare and swap, 即 “比较并交换”. 相当于通过一个原子的操作, 同时完成 “读取内存, 比
    较是否相等, 修改内存” 这三个步骤. 本质上需要 CPU 指令的支撑.

  2. ABA问题怎么解决?
    给要修改的数据引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期.
    如果发现当前版本号和之前读到的版本号一致, 就真正执行修改操作, 并让版本号自增; 如果发现当
    前版本号比之前读到的版本号大, 就认为操作失败


三、synchronized 中的锁优化机制

1、加锁工作过程

Java 的版本非常多,在这些版本变迁的过程中,很多地方都有了不少的变化,我们只考虑 JDK 1.8

JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次升级 。


1.1、偏向锁

偏向锁不是真的加锁, 而只是在锁的对象头中记录一个标记(记录该锁所属的线程),如果没有其他线程参与竞争锁,那么就不会真正执行加锁操作,从而降低程序开销,一旦真的涉及到其他的线程竞争,再取消偏向锁状态,进入轻量级锁状态

Java 多线程八股文 | 常见的锁策略、synchronized中的锁优化机制
举个栗子理解偏向锁 :

有一天我看上了一个小哥哥,长的又帅又有钱
万一后面有一天,我腻歪了,然后想把他甩了,但是他要是对我纠缠不休,这还麻烦

  • 我就只是和这个小哥哥搞暧昧。同时,又不明确我们彼此的关系。
  • 这样做的目的就是为了有朝一日,我想换男朋友了,就直接甩了就行
  • 但是如果再这个过程中,有另外一个妹子,也在对这个小哥哥频频示好
    我就需要提高警惕了,对于这种情况,就要立即和小哥哥确认关系 (男女朋友的关系),立即对另外的妹子进行回击:他是我男朋友。你离他远点

偏向锁 并不是真的加锁,只是做了一个标记
带来的好处就是,后续如果没人竞争的时候,就避免了加锁解锁的开销
偏向锁,升级到轻量级锁的过程
如果没有其他的妹子和我竞争,就一直不去确立关系,(节省了确立关系 / 分手的开销)
如果没有其他的线程来竞争这个锁,就不必真的加锁,(节省了加锁解锁的开销)

文里的偏向锁,和懒汉模式也有点像,思路都是一致的,只是在必要的时候,才进行操作,如果不必要,则能省就省


1.2、轻量级锁

着其他线程进入竞争,偏向锁状态被消除,进入轻量级锁状态 (自适应的自旋锁).
此处的轻量级锁就是通过 CAS 来实现

  • 通过 CAS 检查并更新一块内存 (比如 null => 该线程引用)
  • 如果更新成功, 则认为加锁成功
  • 如果更新失败, 则认为锁被占用, 继续自旋式的等待(并不放弃 CPU)

自旋操作是一直让 CPU 空转, 比较浪费 CPU 资源.
因此此处的自旋不会一直持续进行, 而是达到一定的时间/重试次数, 就不再自旋了.
也就是所谓的 “自适应”


1.3、重量级锁

如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁
此处的重量级锁就是指用到内核提供的 mutex

  • 执行加锁操作, 先进入内核态.
  • 在内核态判定当前锁是否已经被占用
  • 如果该锁没有占用, 则加锁成功, 并切换回用户态.
  • 如果该锁被占用, 则加锁失败. 此时线程进入锁的等待队列, 挂起. 等待被操作系统唤醒.
  • 经历了一系列的沧海桑田, 这个锁被其他线程释放了, 操作系统也想起了这个挂起的线程, 于是唤醒这个线程, 尝试重新获取锁

2、synchronized 几个典型的优化手段

2.1、锁膨胀 / 锁升级

体现了 synchronized 能够"自适应"这样的能力


2.2、锁粗化

一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化

此处的粗细指的是“锁的粒度",锁的粒度: 粗和细

加锁代码涉及到的范围,加锁代码的范围越大,认为锁的粒度越粗范围越小,则认为粒度越细

Java 多线程八股文 | 常见的锁策略、synchronized中的锁优化机制
实际开发过程中, 使用细粒度锁, 是期望释放锁的时候其他线程能使用锁.
但是实际上可能并没有其他线程来抢占这个锁. 这种情况 JVM 就会自动把锁粗化, 避免频繁申请释放锁

举个栗子理解锁粗化 :

滑稽老哥当了领导, 给下属交代工作任务:
方式一:

  • 打电话, 交代任务1, 挂电话.
    打电话, 交代任务2, 挂电话.
    打电话, 交代任务3, 挂电话.

方式二:

  • 打电话, 交代任务1, 任务2, 任务3, 挂电话.

显然, 方式二是更高效的方案.

到底锁粒度是粗好还是细好?各有各的好
如果锁粒度比较细,多个线程之间的并发性就更高
如果锁粒度比较粗,加锁解锁的开销就更小
编译器就会有一个优化,就会自动判定,如果某个地方的代码锁的粒度太细了就会进行粗化
如果两次加锁之间的间隔较大 (中间隔的代码多),一般不会进行这种优化;如果加锁之间间隔比较小 (中间隔的代码少),就很可能触发这个优化


2.3、锁消除

有些代码,明明不用加锁,结果你给加上锁了
编译器就会判断锁没有什么必要,就直接把锁给去掉了
有的时候加锁操作并不是很明显,稍不留神就做出了这种错误的决定
StringBuffer,Vector…在标准库中进行了加锁操作
在单个线程中用到了上述的类,就是单线程进行了加锁解锁

StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");

此时每个 append 的调用都会涉及加锁和解锁,但如果只是在单线程中执行这个代码,那么这些加锁解锁操作是没有必要的,白白浪费了一些资源开销


四、Callable 接口

Java 中的 JUC:``java.util.concurrent`
并发 (多线程相关的操作)

Callable 是一个 interface ,也是一种创建线程的方式,相当于把线程封装了一个 “返回值”,方便程序猿借助多线程的方式计算结果. Runnable 不太适合于让线程计算出一个结果这样的代码

例如,像创建一个线程,让这个线程计算 1+2+ 3 + …+ 1000。如果基于 Runnable 来实现,就会比较麻烦,Callable 就是要解决 Runnable 不方面返回结果这个问题的

代码示例: 创建线程计算 1 + 2 + 3 + … + 1000, 不使用 Callable 版本

  • 创建一个类 Result , 包含一个 sum 表示最终结果,lock 表示线程同步使用的锁对象.
  • main 方法中先创建 Result 实例,然后创建一个线程 t. 在线程内部计算 1 + 2 + 3 + … + 1000.
  • 主线程同时使用 wait 等待线程 t 计算结束. (注意, 如果执行到 wait 之前, 线程 t 已经计算完了, 就不必等待了).
  • 当线程 t 计算完毕后,通过 notify 唤醒主线程,主线程再打印结果
import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;

public class Test {
    public static void main(String[] args) {
        //通过callable来描述一个这样的任务~~
        Callable<Integer> callable = new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                int sum = 6;
                for (int i = 1; i <= 1000; i++){
                    sum += i;
                }
                return sum;
            }
        };
        //为了让线程执行 callable中的任务,光使用构造方法还不够,还需要一个辅助的类.
        FutureTask<Integer> task = new FutureTask<>(callable);
        //创建线程,来完成这里的大算工作
        Thread t = new Thread(task);

		// 凭小票去获取自己地麻辣烫
        // 如果线程的任务没有完成,get 就会阻塞,一直到任务完成了,结果算出来了
        try {
            System.out.println(task.get());
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
    }
}

运行结果:500500

可以看到, 使用 Callable 和 FutureTask 之后, 代码简化了很多, 也不必手动写线程同步代码了

Java 多线程八股文 | 常见的锁策略、synchronized中的锁优化机制

理解 Callable

Callable 和 Runnable 相对, 都是描述一个 “任务”. Callable 描述的是带有返回值的任务,
Runnable 描述的是不带返回值的任务.

Callable 通常需要搭配 FutureTask 来使用. FutureTask 用来保存 Callable 的返回结果. 因为
Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定.

FutureTask 就可以负责这个等待结果出来的工作

理解 FutureTask

想象去吃麻辣烫. 当餐点好后, 后厨就开始做了. 同时前台会给你一张 “小票” . 这个小票就是
FutureTask. 后面我们可以随时凭这张小票去查看自己的这份麻辣烫做出来了没


五、JUC(java.util.concurrent) 的常见类

1、ReentrantLock

可重入互斥锁,和 synchronized 定位类似,都是用来实现互斥效果,保证线程安全
ReentrantLock 也是可重入锁,“Reentrant” 这个单词的原意就是 “可重入”

ReentrantLock 的用法:
lock(): 加锁, 如果获取不到锁就死等
trylock(超时时间):加锁,如果获取不到锁,等待一定的时间之后就放弃加锁
unlock():解锁

把加锁和解锁两个操作分开了

ReentrantLock lock = new ReentrantLock();
// -----------------------------------------

lock.lock();
try {
	// working
} finally {
	lock.unlock() // 保证不管是否异常都能执行到 unlock, 这么写比较麻烦
}

这种分开的做法不太好,很容易遗漏 unlock (容易出现死锁),当多个线程竞争同一个锁的时候就会阻塞…

ReentrantLock 和 synchronized 的区别:

  1. synchronized 是一个关键字,是 JVM 内部实现的(大概率是基于 C++ 实现)。 ReentrantLock 是标准库的一个类, 在 JVM 外实现的(基于 Java 实现).

  2. synchronized 使用时不需要手动释放锁,出了代码块,锁自然释放。 ReentrantLock 使用时需要手动释放. 使用起来更灵活,但是也容易遗漏 unlock,要谨防忘记释放。

  3. synchronized 在竞争锁锁失败时,会阻塞等待,死等。ReentrantLock 除了阻塞等待这一手之外,还有一手 trylock ,给了我们更多的回旋余地,等待一段时间就放弃,直接返回。

  4. synchronized 是非公平锁,ReentrantLock 默认是非公平锁,提供了非公平和公平锁两个版本,可以通过构造方法传入一个 true 开启公平锁模式

  5. 更强大的唤醒机制,基于 synchronized 衍生出来的等待机制是通过 Objectwait / notify 实现等待–唤醒,每次唤醒的是一
    个随机等待的线程,功能是相对有限的。基于 ReentrantLock 衍生出来的等待机制,是 Condition 类 (条件变量) 实现等待–唤醒,功能要更丰富一些,可以更精确控制唤醒某个指定的线程

// ReentrantLock 的构造方法
public ReentrantLock(boolean fair) {
	sync = fair ? new FairSync() : new NonfairSync();
}  

如何选择使用哪个锁?
锁竞争不激烈的时候,使用 synchronized,效率更高,自动释放更方便
锁竞争激烈的时候,使用 ReentrantLock,搭配 trylock 更灵活控制加锁的行为,而不是死等
如果需要使用公平锁,使用 ReentrantLock

日常开发中,绝大部分情况下,synchronized就够用了!


2、原子类

原子类内部用的是 CAS 实现,所以性能要比加锁实现 i++ 高很多。原子类有以下几个

AtomicBoolean
AtomicInteger
AtomicIntegerArray
AtomicLong
AtomicReference
AtomicStampedReference

以 AtomicInteger 举例,常见方法有

num.incrementAndGet(); // ++num
num.decrementAndGet(); // --num
num.getAndIncrement(); // num++;
num.getAndDecrement(); // num--;
num.getAndAdd(10);     // += 10

3、线程池

虽然创建销毁线程比创建销毁进程更轻量,但是在频繁创建销毁线程的时候还是会比较低效

线程池就是为了解决这个问题. 如果某个线程不再使用了, 并不是真正把线程释放, 而是放到一个 "池子"中, 下次如果需要用到线程就直接从池子中取, 不必通过系统来创建了

1)ExecutorService 和 Executors:

代码示例:

  • ExecutorService 表示一个线程池实例.

  • Executors 是一个工厂类, 能够创建出几种不同风格的线程池.

  • ExecutorService 的 submit 方法能够向线程池中提交若干个任务.

ExecutorService pool = Executors.newFixedThreadPool(10);
pool.submit(new Runnable() {
	@Override
	public void run() {
		System.out.println("hello");
	}
});

Executors 创建线程池的几种方式 :

newFixedThreadPool: 创建固定线程数的线程池
newCachedThreadPool: 创建线程数目动态增长的线程池.
newSingleThreadExecutor: 创建只包含单个线程的线程池.
newScheduledThreadPool: 设定 延迟时间后执行命令,或者定期执行命令. 是进阶版的 Timer.
Executors 本质上是 ThreadPoolExecutor 类的封装

2)ThreadPoolExecutor:

ThreadPoolExecutor 提供了更多的可选参数, 可以进一步细化线程池行为的设定

ThreadPoolExecutor 的构造方法

理解 ThreadPoolExecutor 构造方法的参数
把创建一个线程池想象成开个公司. 每个员工相当于一个线程.

  • corePoolSize: 正式员工的数量. (正式员工, 一旦录用, 永不辞退)
  • maximumPoolSize: 正式员工 + 临时工的数目. (临时工: 一段时间不干活, 就被辞退).
  • keepAliveTime: 临时工允许的空闲时间.
  • unit: keepaliveTime 的时间单位, 是秒, 分钟, 还是其他值.
  • workQueue: 传递任务的阻塞队列
  • threadFactory: 创建线程的工厂, 参与具体的创建线程工作.
  • RejectedExecutionHandler: 拒绝策略, 如果任务量超出公司的负荷了接下来怎么处理.
  • AbortPolicy(): 超过负荷, 直接抛出异常.
  • CallerRunsPolicy(): 调用者负责处理
  • DiscardOldestPolicy(): 丢弃队列中最老的任务.
  • DiscardPolicy(): 丢弃新来的任务

代码示例:

ExecutorService pool = new ThreadPoolExecutor(1, 2, 1000, TimeUnit.MILLISECONDS,
                                            new SynchronousQueue<Runnable>(),
                                            Executors.defaultThreadFactory(),
                                            new ThreadPoolExecutor.AbortPolicy());
for(int i=0;i<3;i++) {
        pool.submit(new Runnable() {
        @Override
        void run() {
            System.out.println("hello");
        }
        });
}

4、信号量 Semaphore

是一个更广义的锁
锁是信号量里第一种特殊情况,叫做 “二元信号量"

理解信号量

开车经常会遇到一个情况,停车,停车场入口一般会有个牌子,上面写着 “当前空闲xx个车位”,
每次有个车开出来,车位数+1
这个牌子就是信号量,描述了可用资源 (车位)的个数,每次申请一个可用资源,计数器就 -1 (称为Р操作)

  • 当有车开进去的时候,就相当于申请一个可用资源,可用车位就 -1 (这个称为信号量的 P 操作)

  • 当有车开出来的时候,就相当于释放一个可用资源,可用车位就 +1 (这个称为信号量的 V 操作)

如果计数器的值已经为 0 了,还尝试申请资源,就会阻塞等待,直到有其他线程释放资源

P 和 V 没有对应的英文单词,提出信号量的人,叫做"迪杰斯特拉” (数学家) ,数据结构中的图=>迪杰斯特拉算法,能够计算两点之间的最短路径

可以看成英文: P acquire 申请,V release释放

锁就可以视为 “二元信号量”,可用资源就一个,计数器的取值非 0 即 1
信号量就把锁推广到了一般情况,可用资源更多的时候,如何处理的
实际开发中,并不会经常用到信号量

信号量,用来表示 “可用资源的个数”,本质上就是一个计数器
使用信号量可以实现 “共享锁”,比如某个资源允许 3 个线程同时使用,那么就可以使用 P 操作作为加锁,V 操作作为解锁,前三个线程的 P 操作都能顺利返回,后续线程再进行 P 操作就会阻塞等待,直到前面的线程执行了 V 操作.

Semaphore 的 PV 操作中的加减计数器操作都是原子的, 可以在多线程环境下直接使用

代码示例:

  • 创建 Semaphore 示例, 初始化为 4, 表示有 4 个可用资源.
  • acquire 方法表示申请资源(P操作), release 方法表示释放资源(V操作)
  • 创建 20 个线程, 每个线程都尝试申请资源, sleep 1秒之后, 释放资源. 观察程序的执行效果
Semaphore semaphore = new Semaphore(4);

Runnable runnable = new Runnable() {
    @Override
    public void run() {
        try {
            System.out.println("申请资源");
            semaphore.acquire();
            System.out.println("我获取到资源了");
            Thread.sleep(1000);
            System.out.println("我释放资源了");
            semaphore.release();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
};

for (int i = 0; i < 20; i++) {
    Thread t = new Thread(runnable);
    t.start();
}

5、CountDownLatch

同时等待 N 个任务执行结束

假设有一场跑步比赛,当所有的选手都冲过终点,此时认为是比赛结束

这样的场景在开发中,也是存在的
例如,多线程下载
迅雷…下载一个比较大的资源 (电影),通过多线程下载就可以提高下载速度
把一个文件拆成多个部分,每个线程负责下载其中的一个部分,得是所有的线程都完成自己的下载,才算整个下载完

countDown 给每个线程里面去调用,就表示到达终点了
await 是给等待线程去调用,当所有的任务都到达终点了,await 就从阻塞中返回,就表示任务完成

好像跑步比赛,10个选手依次就位,哨声响才同时出发;所有选手都通过终点,才能公布成绩

  • 构造 CountDownLatch 实例,初始化 10 表示有 10 个任务需要完成
  • 每个任务执行完毕,都调用 latch.countDown() ,在 CountDownLatch 内部的计数器同时自减.
  • 主线程中使用 latch.await(); 阻塞等待所有任务执行完毕,相当于计数器为 0 了
import java.util.concurrent.CountDownLatch;

public class Test2 {
    public static void main(String[] args) throws InterruptedException {
        // 构造方法的参数表示有几个选手
        CountDownLatch latch = new CountDownLatch(10);
        for (int i = 0; i < 10; i++) {
            Thread t = new Thread(() -> {
                try {
                    Thread.sleep(3000);
                    System.out.println(Thread.currentThread().getName() + " 到达终点");
                    latch.countDown(); // 调用 countDown 的次数和个数一致,此时就会await返回的情况
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
            t.start();
        }

        // 裁判要等所有线程到达
        // 当这些线程没有执行完的时候,wait 就会阻塞,所有线程执行完了,await 才返回
        latch.await();
        System.out.println("比赛结束");
    }
}

运行结果:

(等待几秒后)

Thread-7 到达终点
Thread-8 到达终点
Thread-6 到达终点
Thread-9 到达终点
Thread-1 到达终点
Thread-0 到达终点
Thread-3 到达终点
Thread-2 到达终点
Thread-4 到达终点
Thread-5 到达终点
比赛结束

Process finished with exit code 0


六、线程安全的集合类

1)、自己使用同步机制 (``synchronized或者ReentrantLock`)

2)、 Collections.synchronizedList(new ArrayList);

synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List
synchronizedList 的关键操作上都带有 synchronized

全部加锁,不如第一种自己加锁灵活

3)、使用 CopyOnWriteArrayLis

写时拷贝,在修改的时候,会创建一份副本出来

CopyOnWrite容器即写时复制的容器。

  • 当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,
  • 添加完元素之后,再将原容器的引用指向新的容器。
    这样做的好处是我们可以对 CopyOnWrite 容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。
    所以 CopyOnWrite 容器也是一种读写分离的思想,读和写不同的容器。

优点:

  • 在读多写少的场景下, 性能很高, 不需要加锁竞争.

缺点:

  • 占用内存较多.
  • 新写的数据不能被第一时间读取到

举例:
有一个 ArrayList
如果咱们是多线程去读这个 ArrayList,此时没有线程安全问题,完全不需要加锁,也不需要其他方面的控制。如果有多线程去写,就是把这个ArrayList 给**复制了一份,先修改副本*

{1,2,3,4} 把 1 改成 100

  • {100,2,3,4} [副本]
  • 当修改完毕,再让副本转正

这样做的好处,就是修改的同时对于读操作,是没有任何影响的,读的时候优先读旧的版本
不会说出现读到一个 “修改了一半” 的中间状态

也叫做 “双缓冲区" 策略
操作系统,创建进程的时候,也是通过写时拷贝。显卡在渲染画面的时候,也是通过类似的机制。

也是适合于读多写少的情况,也是适合于数据小的情况
更新配置数据,经常会用到这种类似的操作


2、多线程环境使用队列

1). ArrayBlockingQueue 基于数组实现的阻塞队列
2). LinkedBlockingQueue 基于链表实现的阻塞队列
3). PriorityBlockingQueue 基于堆实现的带优先级的阻塞队列
4). TransferQueue  最多只包含一个元素的阻塞队列  

3、多线程下使用哈希表 [最常考的问题之一]

HashMap 本身不是线程安全的
在多线程环境下使用哈希表可以使用:

  • Hashtable [不推荐]
  • ConcurrentHashMap [推荐]

3.1、HashTable

HashTable 如何保证线程安全的?就是给关键方法加锁:

public synchronized V put(K key, V value) {

public synchronized V get(Object key) {

针对 this 来加锁
当有多个线程来访问这个 HashTable 的时候,无论是啥样的操作,无论是啥样的数据,都会出现锁竞争
这样的设计就会导致锁竞争的概率非常大,效率就比较低!

如果多线程访问同一个 Hashtable 就会直接造成锁冲突
size 属性也是通过 synchronized 来控制同步,也是比较慢的
一旦触发扩容,就由该线程完成整个扩容过程,这个过程会涉及到大量的元素拷贝,效率会非常低

举例:

有个公司,老板。若干部门,每个部门有领导,也有基层员工
设老板规定:
员工要想请假,都得找他当面来申请,他好签字
由于公司里的人很多,很多人都要请假
这个时候大家都在老板门口排队,非常不方便!

  1. 每个 HashTable 对象只有一把锁

Java 多线程八股文 | 常见的锁策略、synchronized中的锁优化机制

如果元素多了,链表就会长,就影响 hash 表的效率,就需要扩容 (增加数组的长度)
扩容就需要创建一个更大的数组,然后把之前旧的元素都给搬运过去 (非常耗时)

  • 解决方案:
    老板自己也不耐烦,直接放权,让批示请假的权限,下放到部门领导

3.2、ConcurrentHashMap

  1. ConcurrentHashMap 里面的情况就是:

Java 多线程八股文 | 常见的锁策略、synchronized中的锁优化机制

操作元素的时候,是针对这个 元素所在的链表的头结点 来加锁的
如果你两个线程操作是针 对两个不同的链表上的元素, 没有线程安全问题,其实不必加锁
由于 hash 表中,链表的数目非常多,每个链表的长度是相对短的,因此就可以保证锁冲突的概率就非常小了

  1. ConcurrentHashMap 减少了锁冲突,就让锁加到每个链表的头结点上 (锁桶)
  2. ConcurrentHashMap 只是针对写操作加锁了,读操作没加锁,而只是使用
  3. ConcurrentHashMap 中更广泛的使用 CAS,进一步提高效率 (比如维护 size 操作)
  4. ConcurrentHashMap 针对扩容,进行了巧妙的化整为零
  • 对于 HashTable 来说,只要你这次 put 触发了扩容,就一口气搬运完,会导致这次 put 非常卡顿
  • 对于 ConcurrentHashMap,每次操作只搬运一点点,通过多次操作完成整个搬运的过程
  • 同时维护一个新的 HashMap 和一个旧的,查找的时候,既需要查旧的也要查新的插入的时候**只插入新的,直到搬运完毕再销毁旧的

4、相关面试题

1)、ConcurrentHashMap的读是否要加锁,为什么?

读操作没有加锁. 目的是为了进一步降低锁冲突的概率. 为了保证读到刚修改的数据, 搭配了volatile 关键字

2)、介绍下 ConcurrentHashMap的锁分段技术?

这个是 Java1.7 中采取的技术. Java1.8 中已经不再使用了. 简单的说就是把若干个哈希桶分成一个"段" (Segment), 针对每个段分别加锁.
目的也是为了降低锁竞争的概率. 当两个线程访问的数据恰好在同一个段上的时候, 才触发锁竞争.

3)、ConcurrentHashMap在jdk1.8做了哪些优化?

取消了分段锁, 直接给每个哈希桶(每个链表)分配了一个锁(就是以每个链表的头结点对象作为锁对象).
将原来 数组 + 链表 的实现方式改进成 数组 + 链表 / 红黑树 的方式. 当链表较长的时候(大于等于 8 个元素)就转换成红黑树

4)、Hashtable和HashMap、ConcurrentHashMap 之间的区别?

HashMap: 线程不安全. key 允许为 null
Hashtable: 线程安全. 使用 synchronized 锁 Hashtable 对象, 效率较低. key 不允许为 null.
ConcurrentHashMap: 线程安全. 使用 synchronized 锁每个链表头结点, 锁冲突概率低, 充分利用CAS 机制. 优化了扩容方式. key 不允许为 null

其他面试题:
1)、谈谈 volatile关键字的用法?

volatile 能够保证内存可见性. 强制从主内存中读取数据. 此时如果有其他线程修改被 volatile 修饰
的变量, 可以第一时间读取到最新的值

2)、Java多线程是如何实现数据共享的?

JVM 把内存分成了这几个区域:

方法区, 堆区, 栈区, 程序计数器.

其中堆区这个内存区域是多个线程之间共享的.

只要把某个数据放到堆内存中, 就可以让多个线程都能访问到

3)、Java创建线程池的接口是什么?参数 LinkedBlockingQueue 的作用是什么?

创建线程池主要有两种方式:

  • 通过 Executors 工厂类创建. 创建方式比较简单, 但是定制能力有限.
  • 通过 ThreadPoolExecutor 创建. 创建方式比较复杂, 但是定制能力强.

LinkedBlockingQueue 表示线程池的任务队列. 用户通过 submit / execute 向这个任务队列中添加任务, 再由线程池中的工作线程来执行任务.

4)、Java线程共有几种状态?状态之间怎么切换的?

  • NEW: 安排了工作, 还未开始行动. 新创建的线程, 还没有调用 start 方法时处在这个状态.
  • RUNNABLE: 可工作的. 又可以分成正在工作中和即将开始工作. 调用 start 方法之后, 并正在
    CPU 上运行/在即将准备运行 的状态.
  • BLOCKED: 使用 synchronized 的时候, 如果锁被其他线程占用, 就会阻塞等待, 从而进入该状
    态.
  • WAITING: 调用 wait 方法会进入该状态.
  • TIMED_WAITING: 调用 sleep 方法或者 wait(超时时间) 会进入该状态.
  • TERMINATED: 工作完成了. 当线程 run 方法执行完毕后, 会处于这个状态.

5)、在多线程下,如果对一个数进行叠加,该怎么做?

  • 使用 synchronized / ReentrantLock 加锁
  • 使用 AtomInteger 原子操作
  1. Servlet是否是线程安全的?

Servlet 本身是工作在多线程环境下.

如果在 Servlet 中创建了某个成员变量, 此时如果有多个请求到达服务器, 服务器就会多线程进行
操作, 是可能出现线程不安全的情况的

7)、Thread和Runnable的区别和联系?

Thread 类描述了一个线程.

Runnable 描述了一个任务.

在创建线程的时候需要指定线程完成的任务, 可以直接重写 Thread 的 run 方法, 也可以使用
Runnable 来描述这个任务

8)、多次start一个线程会怎么样

第一次调用 start 可以成功调用.

后续再调用 start 会抛出 java.lang.IllegalThreadStateException 异常

9)、有synchronized两个方法,两个线程分别同时用这个方法,请问会发生什么?

synchronized 加在非静态方法上, 相当于针对当前对象加锁.
如果这两个方法属于同一个实例:

  • 线程1 能够获取到锁, 并执行方法. 线程2 会阻塞等待, 直到线程1 执行完毕, 释放锁, 线程2 获取到
    锁之后才能执行方法内容.

如果这两个方法属于不同实例:

  • 两者能并发执行, 互不干扰

10)、进程和线程的区别?

  • 进程是包含线程的,每个进程至少有一个线程存在,即主线程。
  • 进程和进程之间不共享内存空间. 同一个进程的线程之间共享同一个内存空间.
  • 进程是系统分配资源的最小单位,线程是系统调度的最小单位

相关文章

暂无评论

暂无评论...