Java笔记-----(3)高效并发编程
- (1)进程与线程的区别(重点掌握)
- ① 程序计数器为什么是线程私有的?
- ② 虚拟机栈和本地方法栈为什么是私有的?
- ③ 线程上下文切换比进程上下文切换快的原因
- ④ 进程之间常见的通信方式
- (2)多线程与单线程的关系
- (3)线程的状态
- (3.1) NEW 新建状态 初始态
- (3.2) RUNNABLE 运行状态
- (3.3) BLOCKED 阻塞状态
- (3.4) WAITING 等待态 无限期等待态
- (3.5) TIMED_WAITING 超时等待态 限期等待状态
- (3.6) TERMINATED 终止态 消亡态
- (4)多线程编程中常用的函数比较
- (4.1)Thread.sleep() 和 Object.wait() 的区别
- (4.2)Thread.join 方法
- (4.3)Thread.yield 方法
- (5)线程活性故障
- (5.1)线程死锁(重点)
- 避免死锁的发生
- (5.2)线程锁死
- ① 信号丢失锁死
- ② 嵌套监视器锁死
- (5.3)活锁
- (5.4)线程饥饿
- (5.5)线程活性故障总结
- (6)线程安全的原子性,可见性与有序性
- (6.1)原子性
- (6.2)可见性
- ① 在单处理器中,为什么也会出现可见性的问题呢?
- ② 可见性如何保证
- (6.3)有序性
- (6.4)解析&总结
- (7)对synchronized关键字的理解(灰常重要!)
- (7.1)内部锁底层实现
- ① synchronized 同步语句块的情况
- ② synchronized 修饰方法的的情况
- (7.2)JDK1.6 之后的对synchronized关键字做的底层优化
- ① 偏向锁
- ② 轻量级锁
- ③ 自旋锁和自适应自旋
- ④ 锁消除
- ⑤ 锁粗化
- (7.3)synchronized内部锁对线程安全的保证
- ① synchronized内部锁对原子性的保证
- ② synchronized内部锁对可见性的保证
- ③ synchronized内部锁对有序性的保证
- (7.4)synchronized使用示例
- synchronized关键字最主要的三种使用方式
- (7.5)双重校验锁实现对象单例(线程安全的单例模式)
- (7.6)JVM对资源(锁)的调度方式
- ① 公平调度方式 (吞吐率低)
- ② 非公平调度方式 (吞吐率高)
- ③ JVM对synchronized内部锁的调度
- (8)对volatile(轻量级锁)关键字的理解(也很重要)
- (8.1)Java内存模型
- (8.2)synchronized 关键字和 volatile 关键字的区别
- (9)ReentrantLock(显示锁)和synchronized(内部锁)的区别(字节面试题)
- (10)Java中的线程池(重要)
- (10.1)ThreadPoolExecutor 类分析
- 线程池的排队策略 & 饱和策略(拒绝策略)
- (10.2)常见的线程池类型
- (10.3)常见的阻塞队列
- (10.4)实现 Runnable 接口和 Callable 接口的区别
- (10.5)执行execute()方法和submit()方法的区别
- (10.6)一个简单的线程池Demo:Runnable+ThreadPoolExecutor
- (10.7)关于线程池,你应该知道的事情
- (11)AQS(AbstractQueuedSynchronizer)的原理与实现
- (11.1) AQS 原理概览
- (11.2) AQS 对资源的共享方式
- (11.3) AQS底层使用了模板方法模式
- (11.4) AQS 组件总结
- ① Semaphore(信号量)-允许多个线程同时访问
- ② CountDownLatch (倒计时器)
- ③ CyclicBarrier(循环栅栏)
- (12)ThreadLocal
- (12.1)ThreadLocal简介
- (12.2)ThreadLocal示例
- (12.3)ThreadLocal原理
- (12.4)ThreadLocal 内存泄露问题
- 弱引用介绍
- (13)Atmoic原子类
- (13.1)JUC包中的原子类
- (13.2)AtomicInteger 的使用
- (13.3)AtomicInteger 类的原理分析
- (14)乐观锁与悲观锁
- (14.1)何谓悲观锁与乐观锁
- ① 悲观锁
- ② 乐观锁
- ③ 两种锁的使用场景
- (14.2)乐观锁常见的两种实现方式
- ① 版本号机制
- ② CAS算法
- (14.3)乐观锁的缺点
- ① ABA 问题
- ② 循环时间长开销大
- ③ 只能保证一个共享变量的原子操作
- (14.4)CAS与synchronized的使用情景
- (15)其余常见多线程知识点
- (15.1)为什么不能直接调用run()方法
- (15.2)并发与并行的区别
- (15.3)什么是happened-before原则?
- (15.4)如何进行无锁化编程?
从Java 5.0开始,JDK中提供了 java.util.concurrent(简称JUC )包,在此包中增加了并发编程中常用的工具类,用于定义线程的自定义子系统,包括线程池、异步IO 和轻量级任务框架等。
多线程并发利用了CPU轮询时间片的特点,在一个线程进入阻塞状态时,可以快速切换到其余线程执行其余操作。CPU轮询时间片有利于提高其资源的利用率,最大限度的利用系统提供的处理能力,有效减少了用户的等待响应时间。但是多线程并发编程也存在着线程活性故障以及如何保证线程安全的问题。
(1)进程与线程的区别(重点掌握)
- 进程是一个“执行中的程序”,是系统进行资源分配和调度的一个独立单位
- 线程是进程的一个实体,一个进程中一般拥有多个线程。线程之间共享地址空间和其它资源(所以通信和同步等操作,线程比进程更加容易)
- 线程一般不拥有系统资源,但是也有一些必不可少的资源(使用ThreadLocal存储) (线程可以拥有独属于自己的资源)
- 线程上下文的切换比进程上下文切换要快很多。
与进程不同的是同类的多个线程共享(进程的堆和方法区资源)(JDK1.8之后的元空间),但每个线程有自己的程序计数器、虚拟机栈和本地方法栈,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。
① 程序计数器为什么是线程私有的?
程序计数器主要有下面两个作用:
- 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:
顺序执行、选择、循环、异常处理
。 - 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
需要注意的是,如果执行的是 native
方法,那么程序计数器记录的是 undefined
地址,只有执行的是 Java 代码时程序计数器记录的才是下一条指令的地址。
所以,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置。
② 虚拟机栈和本地方法栈为什么是私有的?
- 虚拟机栈: 每个 Java 方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
- 本地方法栈: 和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
所以,为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。
③ 线程上下文切换比进程上下文切换快的原因
任务从保存到再加载的过程就是一次上下文切换。
上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。
Linux
相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。
- 进程切换时,涉及到当前进程的CPU环境的保存和新被调度运行进程的CPU环境的设置
- 线程切换时,仅需要保存和设置少量的寄存器内容,不涉及存储管理方面的操作
④ 进程之间常见的通信方式
- 通过使用套接字Socket来实现不同机器间的进程通信
- 通过映射一段可以被多个进程访问的共享内存来进行通信
- 通过写进程和读进程利用管道进行通信
参考文章:
利用管道实现进程间通信Java使用管道实现进程间通讯
(2)多线程与单线程的关系
- 多线程是指在一个进程中,并发执行了多个线程,每个线程都实现了不同的功能
- 在单核CPU中,将CPU分为很小的时间片,在每一时刻只能有一个线程在执行,是一种微观上轮流占用CPU的机制。由于【CPU轮询】的速度非常快,所以看起来像是“同时”在执行一样
- 多线程会存在线程上下文切换,会导致程序执行速度变慢
- 多线程不会提高程序的执行速度,反而会降低速度。但是对于用户来说,可以减少用户的等待响应时间,提高了资源的利用效率
多线程并发利用了CPU轮询时间片的特点,在一个线程进入阻塞状态时,可以快速切换到其余线程执行其余操作,这有利于提高资源的利用率,最大限度的利用系统提供的处理能力,有效减少了用户的等待响应时间。
但是,多线程并发编程也会带来数据的安全问题,线程之间的竞争也会导致线程死锁和锁死等活性故障。线程之间的上下文切换也会带来额外的开销等问题。
(3)线程的状态
(3.1) NEW 新建状态 初始态
一个已经创建的线程,但是还没有调用start方法启动的线程所处的状态。
创建一个Thread对象, 但还未调用start()
启动线程时所处的状态.
(3.2) RUNNABLE 运行状态
有可能正在运行,或者正在等待CPU资源。总体上就是当我们创建线程并且启动之后,就属于Runnable状态。
在Java中,运行态包括 就绪态 和 运行态。
- 就绪态(ready)
该状态下的线程已经获得执行所需的所有资源,只要 CPU分配执行权(调度程序选中)就能运行。
所有就绪态的线程存放在就绪队列中。 - 运行态(runnable)
获得 CPU执行权,正在执行的线程。
由于一个 CPU同一时刻只能执行一条线程,因此每个CPU每个时刻只有一条运行态的线程。
进入就绪状态的几种方式:
- 新建的线程调用
start()
方法进入就绪状态。 - 运行中线程时间片用完了,调用该线程的
yield()
方法进入就绪状态。 - 等待锁资源的线程拿到对象锁后进入就绪状态。
- 当前线程
sleep()
结束、其他线程join()
结束、等待用户输入完毕、某个线程拿到对象锁,这些线程也将进入就绪状态。
(3.3) BLOCKED 阻塞状态
- 阻塞状态,当线程准备进入synchronized同步块或同步方法的时候,需要申请一个监视器锁而进行的等待,会使线程进入BLOCKED状态。
- 当一条正在执行的线程请求某一资源失败时,就会进入阻塞态。
- 而在 Java中,阻塞态专指请求锁失败时进入的状态。
- 由一个阻塞队列存放所有阻塞态的线程。
- 处于阻塞态的线程会不断请求资源,一旦请求成功,就会进入就绪队列,等待执行。
(3.4) WAITING 等待态 无限期等待态
该状态的出现是因为调用了Object.wait()或者Thread.join()或者LockSupport.park()。处于该状态下的线程在等待另一个线程执行一些其余action来将其唤醒,否则不会被分配 CPU 时间片。
- 当前线程中调用
Object.wait()、Thread.join()、LockSupport.park()
函数时,当前线程就会进入等待态。 - 也有一个等待队列存放所有等待态的线程。
- 线程处于等待态表示它需要等待其他线程的指示(通知或中断)才能继续运行。
- 进入等待态的线程会释放 CPU执行权,并释放资源(如:锁)
阻塞与等待的区别:
- 阻塞状态是等待着获取到一个排他锁,进入阻塞状态都是被动的,离开阻塞状态是因为其它线程释放了锁,不阻塞了。
- 等待状态是在等待一段时间或者某个唤醒动作的发生,进入等待状态是主动的。
(3.5) TIMED_WAITING 超时等待态 限期等待状态
该状态和上一个状态其实是一样的,只不过其等待的时间是明确的, 到了超过时间后自行返回。
- 无需等待其它线程显式地唤醒,在一定时间之后会被系统自动唤醒。
- 调用
Thread.sleep()
方法使线程进入限期等待状态时,常常用“使一个线程睡眠”进行描述。 - 调用
Object.wait()
方法使线程进入限期等待或者无限期等待时,常常用“挂起一个线程”进行描述。 - 睡眠和挂起是用来描述行为,而阻塞和等待用来描述状态。
- 阻塞和等待的区别在于,阻塞是被动的,它是在等待获取一个排它锁。而等待是主动的,通过调用
Thread.sleep()
和Object.wait()
等方法进入。
- 当运行中的线程调用
sleep(time)、wait、join、parkNanos、parkUntil
时,就会进入该状态; - 它和等待态一样,并不是因为请求不到资源,而是主动进入,并且进入后需要其他线程唤醒;
- 进入该状态后释放 CPU执行权 和 占有的资源。
- 与等待态的区别:到了超时时间后自动进入阻塞队列,开始竞争锁。
(3.6) TERMINATED 终止态 消亡态
线程执行结束后的状态,run方法执行结束表示线程处于消亡状态了。
(4)多线程编程中常用的函数比较
(4.1)Thread.sleep() 和 Object.wait() 的区别
- sleep方法:是Thread类的静态方法,当前线程将睡眠n毫秒,线程进入阻塞状态。当睡眠时间到了,会解除阻塞,进入可运行状态,等待CPU的到来。睡眠不释放锁(如果有的话)。
- wait方法:是Object类的方法,必须与synchronized关键字一起使用,线程进入阻塞状态,当notify或者notifyall被调用后,会解除阻塞。但是,只有重新占用互斥锁之后才会进入可运行状态。睡眠时,会释放互斥锁。
- 两者最主要的区别在于:sleep 方法没有释放锁,而 wait 方法释放了锁 。
- 两者都可以暂停线程的执行。
- Wait 通常被用于线程间交互/通信,sleep 通常被用于暂停执行。
- wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法;
sleep() 方法执行完成后,线程会自动苏醒。或者可以使用 wait(long timeout)超时后线程会自动苏醒。
(4.2)Thread.join 方法
当前线程调用,则其它线程全部停止,等待当前线程执行完毕,接着执行。
运行态到阻塞态(释放锁),线程执行完毕后阻塞态到就绪态
意思就是如果在主线程中调用该方法时就会让主线程休眠,让调用该方法的线程run方法先执行完毕之后在开始执行主线程,不使用join方法的话主和子一起执行,main开始,main结束。
本质是对Object类的wait()
方法做了包装。
如果某个线程在另一个线程t上调用t.join()
,此线程将被挂起,直到目标线程t结束才恢复(即t.isAlive()
方法返回假)。
刷完抖音再睡觉,这个例子比较好理解 在睡觉线程里 调用 刷抖音线程.join, 则会刷完抖音再睡觉
(4.3)Thread.yield 方法
该方法使得线程放弃当前分得的 CPU 时间。但是不使线程阻塞,即线程仍处于可执行状态,随时可能再次分得 CPU 时间。不会释放锁。
Thread.yield()
方法作用是:暂停当前正在执行的线程对象,并执行其他线程。yield()
应该做的是让当前运行线程回到可运行状态,以允许具有相同优先级的其他线程获得运行机会。因此,使用yield()
的目的是让相同优先级的线程之间能适当的轮转执行。但是,实际中无法保证yield()
达到让步目的,因为让步的线程还有可能被线程调度程序再次选中。
结论:yield()
从未导致线程转到等待/睡眠/阻塞状态。在大多数情况下,yield()
将导致线程从运行状态转到可运行状态,但有可能没有效果。
(5)线程活性故障
由于资源的稀缺性或者程序自身的问题导致线程一直处于非Runnable状态,并且其处理的任务一直无法完成的现象被称为是线程活性故障。常见的线程活性故障包括死锁,锁死,活锁与线程饥饿。
每一个线程都有其特定的任务处理逻辑。由于资源的稀缺性或者资源本身的一些特性,导致多个线程需要共享一些排他性资源,比如说处理器,数据库连接等。当出现资源争用的时候,部分线程会进入等待状态。
(5.1)线程死锁(重点)
多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
如下图所示,线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。
下面通过一个例子来说明线程死锁,代码模拟了上图的死锁的情况 (代码来源于《并发编程之美》):
public class DeadLockDemo {
private static Object resource1 = new Object();//资源 1
private static Object resource2 = new Object();//资源 2
public static void main(String[] args) {
new Thread(() -> {
synchronized (resource1) {
System.out.println(Thread.currentThread() + "get resource1");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "waiting get resource2");
synchronized (resource2) {
System.out.println(Thread.currentThread() + "get resource2");
}
}
}, "线程 1").start();
new Thread(() -> {
synchronized (resource2) {
System.out.println(Thread.currentThread() + "get resource2");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "waiting get resource1");
synchronized (resource1) {
System.out.println(Thread.currentThread() + "get resource1");
}
}
}, "线程 2").start();
}
}
死锁是最常见的一种线程活性故障。死锁的起因是多个线程之间相互等待对方而被永远暂停(处于非Runnable)。死锁的产生必须满足如下四个必要条件:
- 资源互斥条件:一个资源每次只能被一个线程使用
- 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放
- 不剥夺条件:线程已经获得的资源,在未使用完之前,不能强行剥夺
- 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系
避免死锁的发生
从死锁产生的四个必要条件的角度出发:
- 破坏资源互斥条件:没办法破坏,临界资源需要互斥访问
- 破坏请求与保持条件:一次性申请所有资源
- 破坏不剥夺条件:申请不到,可以主动释放它占有的资源
- 破坏循环等待条件:按某一顺序申请资源,释放资源则反序释放
- 粗锁法:使用一个粒度粗的锁来消除“请求与保持条件”,缺点是会明显降低程序的并发性能并且会导致资源的浪费。
- 锁排序法:(重点)
指定获取锁的顺序,比如某个线程只有获得A锁和B锁,才能对某资源进行操作,在多线程条件下,如何避免死锁?
通过指定锁的获取顺序,比如规定,只有获得A锁的线程才有资格获取B锁,按顺序获取锁就可以避免死锁。这通常被认为是解决死锁很好的一种方法。 - 使用显式锁中的
ReentrantLock.try(long,TimeUnit)
来申请锁
(5.2)线程锁死
线程锁死是指等待线程由于唤醒其所需的条件永远无法成立,或者其他线程无法唤醒这个线程而一直处于非运行状态(线程并未终止)导致其任务一直无法进展。
线程死锁和线程锁死的外部表现是一致的,即故障线程一直处于非运行状态使得其所执行的任务没有进展。但是锁死的产生条件和线程死锁不一样,即使产生死锁的4个必要条件都没有发生,线程锁死仍然可能已经发生。
线程锁死分为了如下两种:
① 信号丢失锁死
信号丢失锁死是因为没有对应的通知线程来将等待线程唤醒,导致等待线程一直处于等待状态。
典型例子是等待线程在执行Object.wait()/Condition.await()
前没有对保护条件进行判断,而此时保护条件实际上可能已经成立,此后可能(并无其他线程)(更新相应保护条件)(涉及的共享变量)(使其成立并通知等待线程),这就使得等待线程一直处于等待状态,从而使其任务一直无法进展。
② 嵌套监视器锁死
嵌套监视器锁死是由于嵌套锁导致等待线程永远无法被唤醒的一种故障。
比如一个线程,只释放了内层锁Y.wait()
,但是没有释放外层锁X
; 但是通知线程必须先获得外层锁X
,才可以通过 Y.notifyAll()
来唤醒等待线程,这就导致出现了嵌套等待现象。
(5.3)活锁
活锁是一种特殊的线程活性故障。当一个线程一直处于运行状态,但是其所执行的任务却没有任何进展称为活锁。比如,一个线程一直在申请其所需要的资源,但是却无法申请成功。
(5.4)线程饥饿
线程饥饿是指线程一直无法获得其所需的资源导致任务一直无法运行的情况。线程调度模式有公平调度和非公平调度两种模式。在线程的非公平调度模式下,就可能出现线程饥饿的情况。
(5.5)线程活性故障总结
- 线程饥饿发生时,如果线程处于可运行状态,也就是其一直在申请资源,那么就会转变为活锁
- 只要存在一个或多个线程因为获取不到其所需的资源而无法进展就是线程饥饿,所以线程死锁其实也算是线程饥饿
- 线程锁死和死锁都是处于非Runnable,而活锁处于Runnable
(6)线程安全的原子性,可见性与有序性
多线程环境下的线程安全主要体现在:原子性,可见性与有序性方面。
(6.1)原子性
定义:
对于涉及到共享变量访问的操作,若该操作从执行线程以外的任意线程来看是不可分割的,那么该操作就是原子操作,该操作具有原子性。即,其它线程不会“看到”该操作执行了部分的中间结果。
实现方式:
- 利用锁的排他性,保证同一时刻只有一个线程在操作一个共享变量
- 利用CAS(Compare And Swap)保证
- Java语言规范中,保证了除long和double型以外的任何变量的写操作都是原子操作
- Java语言规范中又规定,
volatile
关键字修饰的变量可以保证其写操作的原子性
有关CAS再自行学习一下:
CAS(Compare and Swap)算法介绍、缺陷和解决思路
注意事项:
- 原子性针对的是多个线程的
共享变量
,所以对于局部变量来说不存在共享问题,也就无所谓是否是原子操作 - 单线程环境下讨论是否是原子操作没有意义
-
volatile
关键字仅仅能保证变量写操作的原子性,不保证复合操作,比如说读写操作的原子性
(6.2)可见性
定义:
可见性是指一个线程对于共享变量的更新,对于后续访问该变量的线程是否可见的问题。
处理器缓存:
- 现代处理器处理速度远大于主内存的处理速度,所以在主内存和处理器之间加入了寄存器,高速缓存,写缓冲器以及无效化队列等部件来加速内存的读写操作。也就是说,我们的处理器可以和这些部件进行读写操作的交互,这些部件可以称为处理器缓存。
- 处理器对内存的读写操作,其实仅仅是与处理器缓存进行了交互。一个处理器的缓存上的内容无法被另外一个处理器读取,所以另外一个处理器必须通过缓存一致性协议来读取的其他处理器缓存中的数据,并且同步到自己的处理器缓存中,这样保证了其余处理器对该变量的更新对于另外处理器是可见的。
① 在单处理器中,为什么也会出现可见性的问题呢?
单处理器中,由于是多线程并发编程,所以会存在线程的上下文切换,线程会将对变量的更新当作上下文存储起来,导致其余线程无法看到该变量的更新。所以单处理器下的多线程并发编程也会出现可见性问题的。
② 可见性如何保证
- 当前处理器需要刷新处理器缓存,使得其余处理器对变量所做的更新可以同步到当前的处理器缓存中
- 当前处理器对(共享变量更新)之后,需要冲刷处理器缓存,使得该更新可以被写入处理器缓存中
(6.3)有序性
定义:
有序性是指(一个处理器上运行的线程所执行的内存访问操作)在(另外一个处理器上运行的线程)来看是否有序的问题。
重排序:
为了提高程序执行的性能,Java编译器在其认为不影响程序正确性的前提下,可能会对源代码顺序进行一定的调整,导致程序运行顺序与源代码顺序不一致。
重排序是对内存读写操作的一种优化,在单线程环境下不会导致程序的正确性问题,但是多线程环境下可能会影响程序的正确性。
重排序举例:Instance instance = new Instance()
都发生了啥?
具体步骤如下所示三步:
- 在堆内存上分配对象的内存空间
- 在堆内存上初始化对象
- 设置instance指向刚分配的内存地址
第二步和第三步可能会发生重排序,导致引用型变量指向了一个不为null但是也不完整的对象。(在多线程下的单例模式中,我们必须通过volatile来禁止指令重排序)
(6.4)解析&总结
- 原子性是一组操作要么完全发生,要么没有发生,其余线程不会看到中间过程的存在。
注意:原子操作+原子操作不一定还是原子操作。 - 可见性是指一个线程对共享变量的更新对于另外一个线程是否可见的问题。
- 有序性是指(一个线程对共享变量的更新)在其余线程看起来是按照什么顺序执行的问题。
- 可以这么认为,原子性 + 可见性 -> 有序性。
(7)对synchronized关键字的理解(灰常重要!)
synchronized
是Java中的一个关键字,是一个(内部锁)。它可以使用在方法上和方法块上
,表示同步方法和同步代码块。在多线程环境下,同步方法或者同步代码块在同一时刻只允许有一个线程在执行,其余线程都在等待获取锁,也就是实现了【整体并发中的局部串行】。
(7.1)内部锁底层实现
① synchronized 同步语句块的情况
public class SynchronizedDemo {
public void method() {
synchronized (this) {
System.out.println("synchronized 代码块");
}
}
}
通过 JDK 自带的 javap 命令
查看 SynchronizedDemo 类的相关字节码信息:首先切换到类的对应目录执行 javac SynchronizedDemo.java
命令生成编译后的 .class 文件,然后执行javap -c -s -v -l SynchronizedDemo.class
。
从上面我们可以看出:
synchronized 同步语句块的实现使用的是 monitorenter
和 monitorexit
指令,其中 monitorenter
指令指向同步代码块的开始位置,monitorexit
指令则指明同步代码块的结束位置。
- 进入时,线程试图获取锁也就是获取 monitor的持有权,执行
monitorenter
,将计数器+1,释放锁monitorexit
时,计数器-1 - 当一个线程判断到计数器为0时,则当前锁空闲,可以占用;反之,当前线程进入等待状态
monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁
的原因
② synchronized 修饰方法的的情况
public class SynchronizedDemo2 {
public synchronized void method() {
System.out.println("synchronized 方法");
}
}
synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED
标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED
访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
(7.2)JDK1.6 之后的对synchronized关键字做的底层优化
JDK1.6 对锁的实现引入了大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销。
锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。
① 偏向锁
引入偏向锁的目的和引入轻量级锁的目的很像,他们都是为了没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。但是不同是:轻量级锁在无竞争的情况下使用 CAS 操作去代替使用互斥量。而偏向锁在无竞争的情况下会把整个同步都消除掉。
偏向锁的“偏”就是偏心的偏,它的意思是会偏向于第一个获得它的线程,如果在接下来的执行中,该锁没有被其他线程获取,那么持有偏向锁的线程就不需要进行同步!
关于偏向锁的原理可以查看《深入理解Java虚拟机:JVM高级特性与最佳实践》第二版的13章第三节锁优化。
但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。
② 轻量级锁
倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的)。轻量级锁不是为了代替重量级锁,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量
产生的性能消耗,因为使用轻量级锁时,不需要申请互斥量
。另外,轻量级锁的加锁和解锁都用到了CAS操作。 关于轻量级锁的加锁和解锁的原理可以查看《深入理解Java虚拟机:JVM高级特性与最佳实践》第二版的13章第三节锁优化。
轻量级锁能够提升程序同步性能的依据是“对于绝大部分锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据。如果没有竞争,轻量级锁使用 CAS 操作避免了使用互斥操作的开销。但如果存在锁竞争,除了互斥量开销外,还会额外发生CAS操作,因此在有锁竞争的情况下,轻量级锁比传统的重量级锁更慢!如果锁竞争激烈,那么轻量级将很快膨胀为重量级锁!
③ 自旋锁和自适应自旋
轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起
,还会进行一项称为自旋锁的优化手段。
互斥同步对性能最大的影响就是阻塞的实现,因为挂起线程/恢复线程的操作都需要转入内核态中完成(用户态转换到内核态会耗费时间)。
一般线程持有锁的时间都不是太长,所以仅仅为了这一点时间去挂起线程/恢复线程是得不偿失的。 所以,虚拟机的开发团队就这样去考虑:“我们能不能让后面来的请求获取锁的线程等待一会而不被挂起呢?看看持有锁的线程是否很快就会释放锁”。为了让一个线程等待,我们只需要让线程执行一个忙循环(自旋),这项技术就叫做自旋
。
百度百科对自旋锁的解释:
何谓自旋锁?它是为实现保护共享资源而提出一种锁机制。其实,自旋锁与互斥锁比较类似,它们都是为了解决对某项资源的互斥使用。无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者,也就说,在任何时刻最多只能有一个执行单元获得锁
。但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态。但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,"自旋"一词就是因此而得名。
自旋锁在 JDK1.6 之前其实就已经引入了,不过是默认关闭的,需要通过--XX:+UseSpinning
参数来开启。JDK1.6及1.6之后,就改为默认开启的了。需要注意的是:自旋等待不能完全替代阻塞,因为它还是要占用处理器时间。如果锁被占用的时间短,那么效果当然就很好了!反之,相反!自旋等待的时间必须要有限度。如果自旋超过了限定次数任然没有获得锁,就应该挂起线程。自旋次数的默认值是10次,用户可以修改--XX:PreBlockSpin
来更改。
另外,在 JDK1.6 中引入了自适应的自旋锁。自适应的自旋锁带来的改进就是:自旋的时间不在固定了,而是和前一次同一个锁上的自旋时间以及锁的拥有者的状态来决定,虚拟机变得越来越“聪明”了。
④ 锁消除
锁消除理解起来很简单,它指的就是虚拟机即使编译器在运行时,如果检测到那些共享数据不可能存在竞争,那么就执行锁消除。锁消除可以节省毫无意义的请求锁的时间。
⑤ 锁粗化
原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小,——直在共享数据的实际作用域才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待线程也能尽快拿到锁。
大部分情况下,上面的原则都是没有问题的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,那么会带来很多不必要的性能消耗。
(7.3)synchronized内部锁对线程安全的保证
① synchronized内部锁对原子性的保证
锁通过互斥来保障原子性,互斥是指一个锁一次只能被一个线程所持有,所以,临界区代码只能被一个线程执行,即保障了原子性。
② synchronized内部锁对可见性的保证
synchronized内部锁通过(写线程冲刷处理器缓存)和(读线程刷新处理器缓存)保证可见性。
- 获得锁之后,需要刷新处理器缓存,使得前面写线程所做的更新可以同步到本线程。
- 释放锁需要冲刷处理器缓存,使得当前线程对共享数据的改变可以被推送到下一个线程处理器的高速缓冲中。
③ synchronized内部锁对有序性的保证
由于原子性和可见性的保证,使得(写线程在临界区中所执行的一系列操作)(在读线程所执行的临界区)看起来像是完全按照源代码顺序执行的,即保证了有序性。原子性+可见性–>有序性
(7.4)synchronized使用示例
synchronized是Java中的关键字,其实就是一个(内部锁)。内部锁可以使用在方法上和代码块上,被内部锁修饰的区域又叫做临界区。如下所示:
public class SynchronizedTest {
public static void main(String[] args) {
synchronized (SynchronizedTest.class){
System.out.println("这是一个同步方法块");
}
}
public synchronized void test(){
System.out.println("这是一个同步方法,因为在方法上使用了synchronized关键字");
}
}
synchronized关键字最主要的三种使用方式
- 修饰实例方法: 作用于(
当前对象实例
)加锁,进入同步代码前要获得(当前对象实例)的锁 - 修饰静态方法: 也就是给(
当前类
)加锁,会作用于(类的所有对象实例
),因为静态成员不属于任何一个实例对象,是类成员( static 表明这是该类的一个静态资源,不管new了多少个对象,只有一份)。所以如果一个线程A调用一个实例对象的非静态 synchronized 方法,而线程B需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象
,因为访问静态 synchronized 方法占用的锁是(当前类的锁),而访问非静态 synchronized 方法占用的锁是(当前实例对象锁)。 - 修饰代码块: 指定加锁对象,对
给定对象
加锁,进入同步代码库前要获得给定对象的锁。
总结:
- synchronized 关键字加到
static 静态方法
和synchronized(class)代码块
上都是是给 Class 类上锁。 - synchronized 关键字加到
实例方法
上是给对象实例上锁。 - 尽量不要使用 synchronized(String a) 。因为JVM中,字符串常量池具有缓存功能!
(7.5)双重校验锁实现对象单例(线程安全的单例模式)
public class Singleton {
private volatile static Singleton uniqueInstance;
private Singleton() {
}
public static Singleton getUniqueInstance() {
//先判断对象是否已经实例过,没有实例化过才进入加锁代码
if (uniqueInstance == null) {
//类对象加锁
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}
uniqueInstance 采用 volatile 关键字
修饰也是很有必要的, uniqueInstance = new Singleton()
;这段代码其实是分为三步执行:
- 为 uniqueInstance 分配内存空间
- 初始化 uniqueInstance
- 将 uniqueInstance 指向分配的内存地址
但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance()
后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。
使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。
(7.6)JVM对资源(锁)的调度方式
锁做为一种资源,JVM对资源的调度分为公平调度和非公平调度方式。
① 公平调度方式 (吞吐率低)
按照申请的先后顺序授予资源的独占权。
- 优点:线程申请资源所需的时间偏差(较小);不会出现线程饥饿的现象;适合在以下几点情况下使用:
1.资源的持有线程占用资源的时间相对长
2.资源的平均申请时间间隔相对长
3.对资源申请所需的时间偏差有所要求 - 缺点:
吞吐率较低
② 非公平调度方式 (吞吐率高)
在该策略中,资源的持有线程释放该资源的时候,等待队列中一个线程会被唤醒,而该线程从被唤醒到其继续执行可能需要一段时间。在该段时间内,新来的线程(活跃线程)可以先被授予该资源的独占权。
如果新来的线程占用该资源的时间不长,那么它完全有可能在被唤醒的线程继续执行前释放相应的资源,从而不影响该被唤醒的线程申请资源。
- 优点:
吞吐率较高
,单位时间内可以为更多的申请者调配资源 - 缺点:资源申请者申请资源所需的时间偏差可能较大,并可能出现线程饥饿的现象
③ JVM对synchronized内部锁的调度
JVM对内部锁的调度是一种非公平的调度方式,JVM会给每个内部锁分配一个入口集(Entry Set),用于记录等待获得相应内部锁的线程。当锁被持有的线程释放的时候,该锁的入口集中的任意一个线程将会被唤醒,从而得到再次申请锁的机会。被唤醒的线程等待占用处理器运行时(可能还有其他新的活跃线程)与(该线程)抢占这个被释放的锁。
(8)对volatile(轻量级锁)关键字的理解(也很重要)
volatile关键字是一个轻量级的锁,可以保证可见性和有序性,但是不保证原子性。
- volatile 可以保证主内存和工作内存直接产生交互,进行读写操作,保证可见性
- volatile 仅能保证变量写操作的原子性,不能保证读写操作的原子性。
- volatile可以禁止指令重排序(通过插入内存屏障),典型案例是在单例模式中使用。
volatile变量的开销:
volatile不会导致线程上下文切换,但是其读取变量的成本较高,因为其每次都需要从高速缓存或者主内存中读取,无法直接从寄存器中读取变量。
volatile在什么情况下可以替代锁?
volatile是一个轻量级的锁,适合多个线程共享(一个)状态变量,锁适合多个线程共享(一组)状态变量。可以将(多个线程共享的一组状态变量)合并成一个对象,用一个volatile变量来引用该对象,从而替代锁。
(8.1)Java内存模型
在 JDK1.2 之前,Java的内存模型实现总是从主存(即共享内存)读取变量,是不需要进行特别的注意的。
而在当前的 Java 内存模型下,线程可以把变量保存本地内存(比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。
要解决这个问题,就需要把变量声明为volatile
,这就指示 JVM,这个变量是不稳定的,每次使用它都到主存中进行读取。
说白了, volatile
关键字的主要作用就是①保证变量的可见性然后还有一个作用是②防止指令重排序。
(8.2)synchronized 关键字和 volatile 关键字的区别
- volatile关键字是线程同步的轻量级实现,所以volatile性能肯定比synchronized关键字要好。但是volatile关键字只能用于变量而synchronized关键字可以修饰方法以及代码块。synchronized关键字在JavaSE1.6之后进行了主要包括为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁以及其它各种优化之后执行效率有了显著提升,实际开发中使用 synchronized 关键字的场景还是更多一些。
- 多线程访问volatile关键字不会发生阻塞,而synchronized关键字可能会发生阻塞
- volatile关键字能保证数据的可见性,但不能保证数据的原子性。synchronized关键字两者都能保证。
- volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized关键字解决的是多个线程之间访问资源的同步性。
(9)ReentrantLock(显示锁)和synchronized(内部锁)的区别(字节面试题)
ReentrantLock
是显示锁,其提供了一些内部锁不具备的特性,但并不是内部锁的替代品。显式锁支持公平和非公平的调度方式,默认采用非公平调度。
-
synchronized
内部锁简单,但是不灵活。 - 显示锁支持在一个方法内申请锁,并且在另一个方法里释放锁。显示锁定义了一个
tryLock()
方法,尝试去获取锁,成功返回true
,失败并不会导致其执行的线程被暂停而是直接返回false
,即可以避免死锁。
① 两者都是可重入锁
两者都是可重入锁。“可重入锁”概念是:自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重入的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。
② synchronized 依赖于 JVM ,而 ReentrantLock 依赖于 API
- synchronized 是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。
- ReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要
lock()
和unlock()
方法配合try/finally
语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。
③ ReentrantLock 比 synchronized 增加了一些高级功能
相比synchronized,ReentrantLock增加了一些高级功能。主要来说主要有三点:
- 等待可中断
ReentrantLock提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()
来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。 - 可实现公平锁
ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。 ReentrantLock默认情况是非公平的,可以通过 ReentrantLock类的ReentrantLock(boolean fair)
构造方法来制定是否是公平的。 - 可实现选择性通知(锁可以绑定多个条件)
synchronized关键字与wait()
和notify()/notifyAll()
方法相结合可以实现等待/通知机制,ReentrantLock类当然也可以实现,但是需要借助于Condition接口与newCondition() 方法
。
Condition是JDK1.5之后才有的,它具有很好的灵活性,比如可以实现多路通知功能,也就是在一个Lock对象中可以创建多个Condition实例(即对象监视器),线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。
在使用notify()/notifyAll()
方法进行通知时,被通知的线程是由 JVM 选择的,用ReentrantLock类结合Condition实例可以实现“选择性通知
” ,这个功能非常重要,而且是Condition接口
默认提供的。
而synchronized关键字就相当于整个Lock对象中只有一个Condition实例,所有的线程都注册在它一个身上。如果执行notifyAll()
方法的话就会通知所有处于等待状态的线程这样会造成很大的效率问题,而Condition实例的signalAll()
方法 只会唤醒注册在该Condition实例中的所有等待线程。
(10)Java中的线程池(重要)
创建线程是有开销的,为了重复利用已创建的线程降低线程创建和销毁的消耗,提高资源的利用效率,所以出现了线程池。
java.util.concurrent.ThreadPoolExecutor
类就是一个线程池。客户端调用ThreadPoolExecutor.submit(Runnable task)
提交任务,线程池内部维护的工作者线程的数量就是该线程池的线程池大小,有3种形态:
- 当前线程池大小:表示线程池中实际工作者线程的数量
- 最大线程池大小(maxinumPoolSize):表示线程池中允许存在的工作者线程的数量上限
- 核心线程大小(corePoolSize):表示一个(不大于最大线程池大小)的工作者线程数量上限
线程池的优势体现如下:
- 线程池可以重复利用已创建的线程,一次创建可以执行多次任务,有效降低线程创建和销毁所造成的资源消耗;
- 线程池技术使得请求可以快速得到响应,节约了创建线程的时间;
- 线程的创建需要占用系统内存,消耗系统资源,使用线程池可以更好的管理线程,做到统一分配、调优和监控线程,提高系统的稳定性。
线程池实现原理:
(10.1)ThreadPoolExecutor 类分析
参考文章:
Java线程池七个参数详解
线程池的参数字段如下所示:
/**
* 用给定的初始参数创建一个新的ThreadPoolExecutor。
*/
public ThreadPoolExecutor(
int corePoolSize, //核心线程数
int maximumPoolSize, //最大线程数
long keepAliveTime, //线程空闲但是保持不被回收的时间
TimeUnit unit, //时间单位
BlockingQueue<Runnable> workQueue, //存储线程的队列 [存储线程任务的队列]
ThreadFactory threadFactory, //创建线程的工厂
RejectedExecutionHandler handler, //拒绝策略
) {
if (corePoolSize < 0 ||
maximumPoolSize <= 0 ||
maximumPoolSize < corePoolSize ||
keepAliveTime < 0)
throw new IllegalArgumentException();
if (workQueue == null || threadFactory == null || handler == null)
throw new NullPointerException();
this.corePoolSize = corePoolSize;
this.maximumPoolSize = maximumPoolSize;
this.workQueue = workQueue;
this.keepAliveTime = unit.toNanos(keepAliveTime);
this.threadFactory = threadFactory;
this.handler = handler;
}
ThreadPoolExecutor 3 个最重要的参数:
corePoolSize : 核心线程数线程数定义了最小可以同时运行的线程数量。
maximumPoolSize : 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。
workQueue: 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。
ThreadPoolExecutor其他常见参数:
keepAliveTime:当线程池中的线程数量大于 corePoolSize 的时候,如果这时没有新的任务提交,核心线程外的线程
不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime才会被回收销毁;
unit : keepAliveTime 参数的时间单位。
threadFactory :executor 创建新线程的时候会用到。
handler :饱和策略。关于饱和策略下面单独介绍一下。
线程池的排队策略 & 饱和策略(拒绝策略)
当我们向线程池提交任务的时候,需要遵循一定的排队策略,具体策略如下:
- 如果运行的线程少于
corePoolSize
,则Executor
始终首选添加新的线程,而不进行排队 - 如果运行的线程等于或者多于
corePoolSize
,则Executor始终首选将请求加入队列,而不是添加新线程 - 如果无法将请求加入队列,即队列已经满了,则创建新的线程,除非创建此线程超出
maxinumPoolSize
,在这种情况下,任务默认将被拒绝。
ThreadPoolExecutor 饱和策略(拒绝策略)定义:
当(提交的任务数)>(workQueue.size() + maximumPoolSize )
,就会触发线程池的拒绝策略。
如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任时,ThreadPoolTaskExecutor
定义一些策略:
-
ThreadPoolExecutor.AbortPolicy
(spring默认):抛出RejectedExecutionException
来拒绝新任务的处理。 -
ThreadPoolExecutor.CallerRunsPolicy
:调用执行自己的线程运行任务。您不会任务请求。但是这种策略会降低对于新任务提交速度,影响程序的整体性能。另外,这个策略喜欢增加队列容量。如果您的应用程序可以承受此延迟并且你不能任务丢弃任何一个任务请求的话,你可以选择这个策略。 -
ThreadPoolExecutor.DiscardPolicy
: 不处理新任务,直接丢弃掉。 -
ThreadPoolExecutor.DiscardOldestPolicy
: 此策略将丢弃最早的未处理的任务请求。
举个例子: Spring 通过 ThreadPoolTaskExecutor
或者我们直接通过 ThreadPoolExecutor
的构造函数创建线程池的时候,当我们不指定 RejectedExecutionHandler
饱和策略的话来配置线程池的时候默认使用的是 ThreadPoolExecutor.AbortPolicy
。
在默认情况下,ThreadPoolExecutor
将抛出 RejectedExecutionException
来拒绝新来的任务 ,这代表你将丢失对这个任务的处理。 对于可伸缩的应用程序,建议使用 ThreadPoolExecutor.CallerRunsPolicy
。当最大池被填满时,此策略为我们提供可伸缩队列。
(10.2)常见的线程池类型
《阿里巴巴Java开发手册》中强制线程池不允许使用 Executors
去创建,而是通过 ThreadPoolExecutor
的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险
Executors 返回线程池对象的弊端如下:
-
FixedThreadPool
和SingleThreadExecutor
: 允许请求的队列长度为Integer.MAX_VALUE
,可能堆积大量的请求,从而导致OOM。 -
CachedThreadPool
和ScheduledThreadPool
: 允许创建的线程数量为Integer.MAX_VALUE
,可能会创建大量线程,从而导致OOM。
newCachedThreadPool( )
- 核心线程池大小为0,最大线程池大小不受限,来一个创建一个线程
- 适合用来执行大量耗时较短且提交频率较高的任务
- 来一个线程就创建一个,这是因为其内部队列使用了SynchronousQueue阻塞队列,所以不存在排队。
- 若有空闲线程可以复用,则会优先使用可复用的线程。
- 若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。
- 所有线程在当前任务执行完毕后,将返回线程池进行复用。
newFixedThreadPool( )
- 固定线程数量的线程池
- 当线程池大小达到核心线程池大小,就不会增加也不会减小工作者线程的固定大小的线程池
newSingleThreadExecutor( )
- 便于实现单(多)生产者-消费者模式
方法返回一个只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。
(10.3)常见的阻塞队列
ArrayBlockingQueue:
- 内部使用一个数组作为其存储空间,数组的存储空间是预先分配的
- 优点是 put 和 take操作不会增加GC的负担(因为空间是预先分配的)
- 缺点是 put 和 take操作使用同一个锁,可能导致锁争用,导致较多的上下文切换。
- ArrayBlockingQueue适合在生产者线程和消费者线程之间的并发程序较低的情况下使用。
LinkedBlockingQueue:
- 是一个无界队列(其实队列长度是
Integer.MAX_VALUE
) - 内部存储空间是一个链表,并且链表节点所需的存储空间是动态分配的
- 优点是 put 和 take 操作使用两个显式锁(
putLock
和takeLock
) - 缺点是增加了GC的负担,因为空间是动态分配的。
- LinkedBlockingQueue适合在生产者线程和消费者线程之间的并发程序较高的情况下使用。
SynchronousQueue:
SynchronousQueue可以被看做一种特殊的有界队列
。生产者线程生产一个产品之后,会等待消费者线程来取走这个产品,才会接着生产下一个产品,适合在生产者线程和消费者线程之间的处理能力相差不大的情况下使用。
newCachedThreadPool
线程池,来一个线程就创建一个,这是因为其内部队列使用了SynchronousQueue,所以不存在排队。
(10.4)实现 Runnable 接口和 Callable 接口的区别
- Runnable 接口,任务不需要返回结果或抛出检查异常推荐使用,这样代码看起来会更加简洁。
- Callable 接口,计算得出的结果,无法执行操作时抛出异常
工具类 Executors 可以实现 Runnable 对象和 Callable 对象之间的相互转换。
Executors.callable(Runnable task)
Executors.callable(Runnable task,Object resule)
Runnable.java
@FunctionalInterface
public interface Runnable {
/**
* 被线程执行,没有返回值也无法抛出异常
*/
public abstract void run();
}
Callable.java
@FunctionalInterface
public interface Callable<V> {
/**
* 计算结果,或在无法这样做时抛出异常。
* @return 计算得出的结果
* @throws 如果无法计算结果,则抛出异常
*/
V call() throws Exception;
}
(10.5)执行execute()方法和submit()方法的区别
- execute()方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否;
- submit()方法用于提交需要返回值的任务。线程池会返回一个
Future
类型的对象,通过这个Future
对象可以判断任务是否执行成功,并且可以通过Future
的get()
方法来获取返回值,get()
方法会阻塞当前线程直到任务完成,而使用get(long timeout,TimeUnit unit)
方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完。
以AbstractExecutorService接口中的一个 submit 方法为例子来看看源代码:
public Future<?> submit(Runnable task) {
if (task == null) throw new NullPointerException();
RunnableFuture<Void> ftask = newTaskFor(task, null);
execute(ftask);
return ftask;
}
上面方法调用的 newTaskFor 方法返回了一个 FutureTask 对象。
protected <T> RunnableFuture<T> newTaskFor(Runnable runnable, T value) {
return new FutureTask<T>(runnable, value);
}
再来看看execute()方法:
public void execute(Runnable command) {
...
}
(10.6)一个简单的线程池Demo:Runnable+ThreadPoolExecutor
MyRunnable.java
import java.util.Date;
/**
* 这是一个简单的Runnable类,需要大约5秒钟来执行其任务。
*/
public class MyRunnable implements Runnable {
private String command;
public MyRunnable(String s) {
this.command = s;
}
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " Start. Time = " + new Date());
processCommand();
System.out.println(Thread.currentThread().getName() + " End. Time = " + new Date());
}
private void processCommand() {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
@Override
public String toString() {
return this.command;
}
}
ThreadPoolExecutorDemo.java
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public class ThreadPoolExecutorDemo {
private static final int CORE_POOL_SIZE = 5;
private static final int MAX_POOL_SIZE = 10;
private static final int QUEUE_CAPACITY = 100;
private static final Long KEEP_ALIVE_TIME = 1L;
public static void main(String[] args) {
//使用阿里巴巴推荐的创建线程池的方式
//通过ThreadPoolExecutor构造函数自定义参数创建
ThreadPoolExecutor executor = new ThreadPoolExecutor(
CORE_POOL_SIZE, //核心线程数为 5。
MAX_POOL_SIZE, //最大线程数 10
KEEP_ALIVE_TIME, //等待时间为 1L。
TimeUnit.SECONDS, //等待时间的单位为 TimeUnit.SECONDS。
new ArrayBlockingQueue<>(QUEUE_CAPACITY), //任务队列为 ArrayBlockingQueue,并且容量为 100;
new ThreadPoolExecutor.CallerRunsPolicy()); //饱和策略为 CallerRunsPolicy
for (int i = 0; i < 10; i++) {
//创建WorkerThread对象(WorkerThread类实现了Runnable 接口)
Runnable worker = new MyRunnable("" + i);
//执行Runnable
executor.execute(worker);
}
//终止线程池
executor.shutdown();
while (!executor.isTerminated()) {
}
System.out.println("Finished all threads");
}
}
Output:
pool-1-thread-2 Start. Time = Tue Nov 12 20:59:44 CST 2019
pool-1-thread-5 Start. Time = Tue Nov 12 20:59:44 CST 2019
pool-1-thread-4 Start. Time = Tue Nov 12 20:59:44 CST 2019
pool-1-thread-1 Start. Time = Tue Nov 12 20:59:44 CST 2019
pool-1-thread-3 Start. Time = Tue Nov 12 20:59:44 CST 2019
pool-1-thread-5 End. Time = Tue Nov 12 20:59:49 CST 2019
pool-1-thread-3 End. Time = Tue Nov 12 20:59:49 CST 2019
pool-1-thread-2 End. Time = Tue Nov 12 20:59:49 CST 2019
pool-1-thread-4 End. Time = Tue Nov 12 20:59:49 CST 2019
pool-1-thread-1 End. Time = Tue Nov 12 20:59:49 CST 2019
pool-1-thread-2 Start. Time = Tue Nov 12 20:59:49 CST 2019
pool-1-thread-1 Start. Time = Tue Nov 12 20:59:49 CST 2019
pool-1-thread-4 Start. Time = Tue Nov 12 20:59:49 CST 2019
pool-1-thread-3 Start. Time = Tue Nov 12 20:59:49 CST 2019
pool-1-thread-5 Start. Time = Tue Nov 12 20:59:49 CST 2019
pool-1-thread-2 End. Time = Tue Nov 12 20:59:54 CST 2019
pool-1-thread-3 End. Time = Tue Nov 12 20:59:54 CST 2019
pool-1-thread-4 End. Time = Tue Nov 12 20:59:54 CST 2019
pool-1-thread-5 End. Time = Tue Nov 12 20:59:54 CST 2019
pool-1-thread-1 End. Time = Tue Nov 12 20:59:54 CST 2019
分析
代码中模拟了 10 个任务,我们配置的核心线程数为 5 、等待队列容量为 100 ,所以每次只可能存在 5 个任务同时执行,剩下的 5 个任务会被放到等待队列中去。当前的 5 个任务之行完成后,才会之行剩下的 5 个任务。
(10.7)关于线程池,你应该知道的事情
- 使用JDK提供的快捷方式创建线程池,比如说
newCachedThreadPool
会出现一些内存溢出的问题,因为队列可以被塞入很多任务。所以,大多数情况下,我们都应该自定义线程池。 - 线程池提供了一些监控API,可以很方便的监控当前以及塞进队列的任务数以及当前线程池已经完成的任务数等。
(11)AQS(AbstractQueuedSynchronizer)的原理与实现
AQS的全称为(AbstractQueuedSynchronizer),这个类在java.util.concurrent.locks包下面:
AQS是一个用来构建锁和同步器的框架,使用AQS能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的ReentrantLock,Semaphore
,其他的诸如ReentrantReadWriteLock,SynchronousQueue,FutureTask
等等皆是基于AQS的。当然,我们自己也能利用AQS非常轻松容易地构造出符合我们自己需求的同步器。
(11.1) AQS 原理概览
AQS核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套(线程阻塞等待)以及(被唤醒时锁分配)的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。
CLH(Craig,Landin,and Hagersten)队列
是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS是将每条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node)来实现锁的分配
。
AQS(AbstractQueuedSynchronizer)
原理图:
AQS使用一个int成员变量来表示同步状态,通过内置的FIFO队列来完成获取资源线程的排队工作。AQS使用CAS对该同步状态进行原子操作实现对其值的修改。
private volatile int state; //共享变量,使用volatile修饰保证线程可见性
状态信息通过protected类型的getState,setState,compareAndSetState
进行操作(CAS)
//返回同步状态的当前值
protected final int getState() {
return state;
}
// 设置同步状态的值
protected final void setState(int newState) {
state = newState;
}
//原子地(CAS操作)将同步状态值设置为给定值update
//如果当前同步状态的值等于expect(期望值)
protected final boolean compareAndSetState(int expect, int update) {
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
(11.2) AQS 对资源的共享方式
AQS定义两种资源共享方式
- Exclusive(独占):只有一个线程能执行,如ReentrantLock。又可分为公平锁和非公平锁:
公平锁:按照线程在队列中的排队顺序,先到者先拿到锁
非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的 - Share(共享):多个线程可同时执行,如
Semaphore、CountDownLatch、CyclicBarrier、ReadWriteLock
。
ReentrantReadWriteLock
可以看成是组合式,因为ReentrantReadWriteLock
也就是读写锁允许多个线程同时对某一资源进行读。
不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源 state 的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。
(11.3) AQS底层使用了模板方法模式
同步器的设计是基于模板方法模式的,如果需要自定义同步器一般的方式是这样(模板方法模式很经典的一个应用):
- 使用者继承
AbstractQueuedSynchronizer
并重写指定的方法。(这些重写方法很简单,无非是对于共享资源state的获取和释放) - 将AQS组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的方法。
这和我们以往通过实现接口的方式有很大区别,这是模板方法模式很经典的一个运用。
AQS使用了模板方法模式,自定义同步器时需要重写下面几个AQS提供的模板方法:
isHeldExclusively()//该线程是否正在独占资源。只有用到condition才需要去实现它。
tryAcquire(int)//独占方式。尝试获取资源,成功则返回true,失败则返回false。
tryRelease(int)//独占方式。尝试释放资源,成功则返回true,失败则返回false。
tryAcquireShared(int)//共享方式。尝试获取资源。负数表示失败;
//0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
tryReleaseShared(int)//共享方式。尝试释放资源,成功则返回true,失败则返回false。
默认情况下,每个方法都抛出 UnsupportedOperationException
。 这些方法的实现必须是内部线程安全的,并且通常应该简短而不是阻塞。AQS类中的其他方法都是final
,所以无法被其他类使用,只有这几个方法可以被其他类使用。
以ReentrantLock
为例,state初始化为0,表示未锁定状态。A线程lock()
时,会调用tryAcquire()
独占该锁并将state+1
。此后,其他线程再tryAcquire()
时就会失败,直到A线程unlock()
到state=0(即释放锁)为止,其它线程才有机会获取该锁。
当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证state是能回到零态的。
再以CountDownLatch
以例,任务分为N个子线程去执行,state也初始化为N(注意N要与线程个数一致)。这N个子线程是并行执行的,每个子线程执行完后countDown()
一次,state会CAS(Compare and Swap)减1。等到所有子线程都执行完后(即state=0),会unpark()
主调用线程,然后主调用线程就会从await()
函数返回,继续后余动作。
一般来说,自定义同步器要么是独占方法,要么是共享方式,他们也只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared
中的一种即可。但AQS也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock
。
(11.4) AQS 组件总结
① Semaphore(信号量)-允许多个线程同时访问
synchronized
和 ReentrantLock
都是一次只允许一个线程访问某个资源,Semaphore(信号量)
可以指定多个线程同时访问某个资源。
② CountDownLatch (倒计时器)
CountDownLatch
是一个同步工具类,用来协调多个线程之间的同步。这个工具通常用来控制线程等待,它可以让某一个线程等待直到倒计时结束,再开始执行。
CountDownLatch
是一个倒计时协调器,它可以实现一个或者多个线程等待其余线程完成一组特定的操作之后,继续运行。CountDownLatch
的内部实现如下:
-
CountDownLatch
内部维护一个计数器,CountDownLatch.countDown()
每被执行一次都会使计数器值减少1。 - 当计数器不为0时,
CountDownLatch.await()
方法的调用将会导致执行线程被暂停,这些线程就叫做该CountDownLatch
上的等待线程。 -
CountDownLatch.countDown()
相当于一个通知方法,当计数器值达到0时,唤醒所有等待线程。当然对应还有指定等待时间长度的CountDownLatch.await(long , TimeUnit)
方法。
再以CountDownLatch
以例,任务分为N个子线程去执行,state也初始化为N(注意N要与线程个数一致)。这N个子线程是并行执行的,每个子线程执行完后countDown()
一次,state会CAS(Compare and Swap)减1
。等到所有子线程都执行完后(即state=0),会unpark()
主调用线程,然后主调用线程就会从await()
函数返回,继续后余动作。
③ CyclicBarrier(循环栅栏)
CyclicBarrier 和 CountDownLatch 非常类似,它也可以实现线程间的技术等待,但是它的功能比 CountDownLatch 更加复杂和强大。主要应用场景和 CountDownLatch 类似。CyclicBarrier
的字面意思是可循环使用(Cyclic)的屏障(Barrier)
。它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。CyclicBarrier
默认的构造方法是 CyclicBarrier(int parties)
,其参数表示屏障拦截的线程数量,每个线程调用await()
方法告诉 CyclicBarrier
我已经到达了屏障,然后当前线程被阻塞。
CyclicBarrier
是一个栅栏,可以实现多个线程相互等待执行到指定的地点,这时候这些线程会再接着执行,在实际工作中可以用来(模拟高并发请求测试。)
可以认为是这样的,当我们爬山的时候,到了一个平坦处,前面队伍会稍作休息,等待后边队伍跟上来,当最后一个爬山伙伴也达到该休息地点时,所有人同时开始从该地点出发,继续爬山。
CyclicBarrier
的内部实现如下:
- 使用
CyclicBarrier
实现等待的线程被称为参与方(Party),参与方只需要执行CyclicBarrier.await()
就可以实现等待,该栅栏维护了一个显示锁,可以识别出最后一个参与方,当最后一个参与方调用await()
方法时,前面等待的参与方都会被唤醒,并且该最后一个参与方也不会被暂停。 -
CyclicBarrier
内部维护了一个计数器变量count = 参与方的个数,调用await
方法可以使得count-1
。当判断到是最后一个参与方时,调用singalAll
唤醒所有线程。
(12)ThreadLocal
使用ThreadLocal维护变量时,其为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立的改变自己的副本,而不会影响其他线程对应的副本。
(12.1)ThreadLocal简介
通常情况下,我们创建的变量是可以被任何一个线程访问并修改的。如果想实现每一个线程都有自己的专属本地变量该如何解决呢? JDK中提供的ThreadLocal
类正是为了解决这样的问题。 ThreadLocal
类主要解决的就是让每个线程绑定自己的值,可以将ThreadLocal
类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据。
如果你创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的本地副本,这也是ThreadLocal变量名的由来。他们可以使用 get()
和 set()
方法来获取默认值或将其值更改为当前线程所存的副本的值,从而避免了线程安全问题。
(12.2)ThreadLocal示例
import java.text.SimpleDateFormat;
import java.util.Random;
public class ThreadLocalExample implements Runnable{
// SimpleDateFormat 不是线程安全的,所以每个线程都要有自己独立的副本
private static final ThreadLocal<SimpleDateFormat> formatter
= ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyyMMdd HHmm"));
public static void main(String[] args) throws InterruptedException {
ThreadLocalExample obj = new ThreadLocalExample();
for(int i=0 ; i<10; i++){
Thread t = new Thread(obj, ""+i);
Thread.sleep(new Random().nextInt(1000));
t.start();
}
}
@Override
public void run() {
System.out.println("Thread Name= " +
Thread.currentThread().getName() +
" default Formatter = " +
formatter.get().toPattern());
try {
Thread.sleep(new Random().nextInt(1000));
} catch (InterruptedException e) {
e.printStackTrace();
}
//formatter pattern is changed here by thread, but it won't reflect to other threads
formatter.set(new SimpleDateFormat());
System.out.println("Thread Name= "+
Thread.currentThread().getName() +
" formatter = " +
formatter.get().toPattern());
}
}
Output:
Thread Name= 0 default Formatter = yyyyMMdd HHmm
Thread Name= 0 formatter = yy-M-d ah:mm
Thread Name= 1 default Formatter = yyyyMMdd HHmm
Thread Name= 2 default Formatter = yyyyMMdd HHmm
Thread Name= 1 formatter = yy-M-d ah:mm
Thread Name= 3 default Formatter = yyyyMMdd HHmm
Thread Name= 2 formatter = yy-M-d ah:mm
Thread Name= 4 default Formatter = yyyyMMdd HHmm
Thread Name= 3 formatter = yy-M-d ah:mm
Thread Name= 4 formatter = yy-M-d ah:mm
Thread Name= 5 default Formatter = yyyyMMdd HHmm
Thread Name= 5 formatter = yy-M-d ah:mm
Thread Name= 6 default Formatter = yyyyMMdd HHmm
Thread Name= 6 formatter = yy-M-d ah:mm
Thread Name= 7 default Formatter = yyyyMMdd HHmm
Thread Name= 7 formatter = yy-M-d ah:mm
Thread Name= 8 default Formatter = yyyyMMdd HHmm
Thread Name= 9 default Formatter = yyyyMMdd HHmm
Thread Name= 8 formatter = yy-M-d ah:mm
Thread Name= 9 formatter = yy-M-d ah:mm
从输出中可以看出,Thread-0已经改变了formatter的值,但仍然是thread-2默认格式化程序与初始化值相同,其他线程也一样。
上面有一段代码用到了创建 ThreadLocal
变量的那段代码用到了 Java8 的知识,它等于下面这段代码,如果你写了下面这段代码的话,IDEA会提示你转换为Java8的格式。因为ThreadLocal类在Java 8中扩展,使用一个新的方法withInitial()
,将Supplier功能接口作为参数。
private static final ThreadLocal<SimpleDateFormat> formatter
= new ThreadLocal<SimpleDateFormat>(){
@Override
protected SimpleDateFormat initialValue()
{
return new SimpleDateFormat("yyyyMMdd HHmm");
}
};
(12.3)ThreadLocal原理
从 Thread
类源代码入手:
public class Thread implements Runnable {
......
//与此线程有关的ThreadLocal值。由ThreadLocal类维护
ThreadLocal.ThreadLocalMap threadLocals = null;
//与此线程有关的InheritableThreadLocal值。由InheritableThreadLocal类维护
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
......
}
从上面Thread
类 源代码可以看出Thread
类中有一个 threadLocals
和 一个 inheritableThreadLocals
变量,它们都是 ThreadLocalMap
类型的变量,我们可以把 ThreadLocalMap
理解为ThreadLocal
类实现的定制化的 HashMap
。默认情况下这两个变量都是null
,只有当前线程调用 ThreadLocal
类的 set
或get
方法时才创建它们,实际上调用这两个方法的时候,我们调用的是ThreadLocalMap
类对应的 get()、set()
方法。
ThreadLocal
类的set()
方法:
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
通过上面这些内容,我们足以得出结论:最终的变量是放在了当前线程的 ThreadLocalMap
中,并不是存在 ThreadLocal
上,ThreadLocal
可以理解为只是ThreadLocalMap
的封装,传递了变量值。 ThrealLocal
类中可以通过Thread.currentThread()
获取到当前线程对象后,直接通过getMap(Thread t)
可以访问到该线程的ThreadLocalMap
对象。
每个Thread
中都具备一个ThreadLocalMap
,而ThreadLocalMap
可以存储(以ThreadLocal
为key
的键值对)。 比如我们在同一个线程中声明了两个 ThreadLocal
对象的话,会使用 Thread
内部都是使用仅有那个ThreadLocalMap
存放数据的,ThreadLocalMap
的( key
就是 ThreadLocal
对象),(value
就是 ThreadLocal
对象调用set
方法设置的值)。
ThreadLocalMap是ThreadLocal的静态内部类。
ThreadLocal内部实现机制:
- 每个线程内部都会维护一个类似HashMap的对象,称为ThreadLocalMap,里边会包含若干了Entry(K-V键值对),相应的线程被称为这些Entry的属主线程
- Entry的Key是一个ThreadLocal实例,Value是一个线程特有对象。Entry的作用是为其属主线程建立起一个ThreadLocal实例与一个线程特有对象之间的对应关系
- Entry对Key的引用是弱引用;Entry对Value的引用是强引用。(引起内存泄漏)
(12.4)ThreadLocal 内存泄露问题
ThreadLocalMap
中使用的 key 为 ThreadLocal
的弱引用,而 value 是强引用。所以,如果 ThreadLocal
没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。这样一来,ThreadLocalMap
中就会出现key为null的Entry。假如我们不做任何措施的话,value 永远无法被GC 回收,这个时候就可能会产生内存泄露。
ThreadLocalMap
实现中已经考虑了这种情况,在调用 set()、get()、remove()
方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal
方法后 最好手动调用remove()
方法。
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
弱引用介绍
如果一个对象只具有弱引用,那就类似于可有可无的生活用品。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它 所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。
弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。
(13)Atmoic原子类
☆☆☆用CAS (compare and swap) + volatile 和 native 方法
来保证原子操作☆☆☆
介绍Atomic之前先来看一个问题吧,i++
操作是线程安全的吗?
i++操作并不是线程安全的,它是一个复合操作,包含三个步骤:
- 拷贝
i
的值到临时变量 - 临时变量
++
操作 - 拷贝回原始变量
i
这是一个复合操作,不能保证原子性,所以这不是线程安全的操作。那么如何实现原子自增等操作呢?
这里就用到了JDK在java.util.concurrent.atomic
包下的AtomicInteger
等原子类了。AtomicInteger
类提供了getAndIncrement
和incrementAndGet
等原子性的自增自减等操作。Atomic等原子类内部使用了CAS来保证原子性。
接下来,我们来看代码吧,首先是使用变量i
的情况:
class ThreadTest implements Runnable {
static int i = 0;
public void run() {
for (int m = 0; m < 1000000; m++) {
i++;
}
}
};
public class Test {
public static void main(String[] args) throws InterruptedException {
ThreadTest mt = new ThreadTest();
Thread t1 = new Thread(mt);
Thread t2 = new Thread(mt);
t1.start();
t2.start();
// 休眠一下,让线程执行完毕。
Thread.sleep(500);
System.out.println(ThreadTest.i);
}
}
该程序的输出是不确定的,比如输出1933446,也就是线程不安全,发生了竟态导致计算结果有误。
当我们使用了Atomic等原子类时,会发现每次输出结果都是2000000,符合我们的程序设计要求。
import java.util.concurrent.atomic.AtomicInteger;
class ThreadTest implements Runnable {
static AtomicInteger i = new AtomicInteger(0);
public void run() {
for (int m = 0; m < 1000000; m++) {
i.getAndIncrement();
}
}
};
public class Test {
public static void main(String[] args) throws InterruptedException {
ThreadTest mt = new ThreadTest();
Thread t1 = new Thread(mt);
Thread t2 = new Thread(mt);
t1.start();
t2.start();
// 休眠一下,让线程执行完毕。
Thread.sleep(500);
System.out.println(ThreadTest.i.get());
}
}
(13.1)JUC包中的原子类
并发包 java.util.concurrent
的原子类都存放在java.util.concurrent.atomic
下,如下图所示
基本类型:
使用原子的方式更新基本类型
- AtomicInteger:整形原子类
- AtomicLong:长整型原子类
- AtomicBoolean:布尔型原子类
数组类型:
使用原子的方式更新数组里的某个元素
- AtomicIntegerArray:整形数组原子类
- AtomicLongArray:长整形数组原子类
- AtomicReferenceArray:引用类型数组原子类
引用类型:
- AtomicReference:引用类型原子类
- AtomicStampedReference:原子更新引用类型里的字段原子类
- AtomicMarkableReference :原子更新带有标记位的引用类型
对象的属性修改类型:
- AtomicIntegerFieldUpdater:原子更新整形字段的更新器
- AtomicLongFieldUpdater:原子更新长整形字段的更新器
- AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用CAS 进行原子更新时可能出现的 ABA 问题。
(13.2)AtomicInteger 的使用
AtomicInteger 类常用方法
public final int get() //获取当前的值
public final int getAndSet(int newValue)//获取当前的值,并设置新的值
public final int getAndIncrement()//获取当前的值,并自增
public final int getAndDecrement() //获取当前的值,并自减
public final int getAndAdd(int delta) //获取当前的值,并加上预期的值
boolean compareAndSet(int expect, int update) //如果输入的数值等于预期值,
//则以原子方式将该值设置为输入值(update)
public final void lazySet(int newValue)//最终设置为newValue,使用 lazySet 设置之后可能导致其他线程
//在之后的一小段时间内还是可以读到旧的值。
AtomicInteger 类的使用示例:
class AtomicIntegerTest {
private AtomicInteger count = new AtomicInteger();
//使用AtomicInteger之后,不需要对 increment() 加锁,也可以实现线程安全。
public void increment() {
count.incrementAndGet();
}
public int getCount() {
return count.get();
}
}
(13.3)AtomicInteger 类的原理分析
AtomicInteger 线程安全原理简单分析
AtomicInteger 类的部分源码:
// setup to use Unsafe.compareAndSwapInt for updates(更新操作时提供“比较并替换”的作用)
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;
static {
try {
valueOffset = unsafe.objectFieldOffset(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) {
throw new Error(ex); }
}
private volatile int value;
AtomicInteger 类主要利用 CAS (compare and swap) + volatile 和 native 方法
来保证原子操作,从而避免 synchronized 的高开销,执行效率大为提升。
CAS的原理是拿期望的值和原本的一个值作比较,如果相同则更新成新的值。UnSafe 类的 objectFieldOffset()
方法是一个本地方法,这个方法是用来拿到“原来的值”的内存地址,返回值是 valueOffset。另外 value 是一个volatile变量,在内存中可见,因此 JVM 可以保证任何时刻任何线程总能拿到该变量的最新值。
(14)乐观锁与悲观锁
(14.1)何谓悲观锁与乐观锁
① 悲观锁
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。
传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中synchronized
和ReentrantLock
等独占锁就是悲观锁思想的实现。
② 乐观锁
总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中java.util.concurrent.atomic
包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。
③ 两种锁的使用场景
从上面对两种锁的介绍,我们知道两种锁各有优缺点,不可认为一种好于另一种,像乐观锁适用于写比较少的情况下(多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果是多写的情况,一般会经常产生冲突,这就会导致上层应用会不断的进行retry,这样反倒是降低了性能,所以一般多写的场景下用悲观锁就比较合适。
(14.2)乐观锁常见的两种实现方式
乐观锁一般会使用版本号机制或CAS算法实现。
① 版本号机制
一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加一。当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。
举一个简单的例子:
假设数据库中帐户信息表中有一个 version 字段,当前值为 1 ;而当前帐户余额字段( balance )为 $100 。
- 操作员 A 此时将其读出( version=1 ),并从其帐户余额中扣除 $50( $100-$50 )
- 在操作员 A 操作的过程中,操作员B 也读入此用户信息(version=1),并从其帐户余额中扣除 $20( $100-$20 )
- 操作员 A 完成了修改工作,将数据版本号加一( version=2 ),连同帐户扣除后余额( balance=$50 ),提交至数据库更新,此时由于提交数据版本大于数据库记录当前版本,数据被更新,数据库记录 version 更新为 2
- 操作员 B 完成了操作,也将版本号加一( version=2 )试图向数据库提交数据( balance=$80 ),但此时比对数据库记录版本时发现,操作员 B 提交的数据版本号为 2 ,数据库记录当前版本也为 2 ,不满足“提交版本必须大于记录当前版本才能执行更新”的乐观锁策略,因此,操作员 B 的提交被驳回
这样,就避免了操作员 B 用基于 version=1 的旧数据修改的结果覆盖操作员A 的操作结果的可能。
② CAS算法
即compare and swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步
,所以也叫非阻塞同步(Non-blocking Synchronization)。
CAS算法涉及到三个操作数:
- 需要读写的内存值 V
- 进行比较的值 A
- 拟写入的新值 B
当且仅当 V 的值等于 A时,CAS通过原子方式用新值B来更新V的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断的重试。
(14.3)乐观锁的缺点
① ABA 问题
如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然是A值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回A,那CAS操作就会误认为它从来没有被修改过。这个问题被称为CAS操作的 "ABA"问题。
JDK 1.5 以后的 AtomicStampedReference
类就提供了此种能力,其中的 compareAndSet
方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
② 循环时间长开销大
自旋CAS(也就是不成功就一直循环执行直到成功)如果长时间不成功,会给CPU带来非常大的执行开销。 如果JVM能支持处理器提供的pause指令那么效率会有一定的提升
,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。
③ 只能保证一个共享变量的原子操作
CAS 只对单个共享变量有效
,当操作涉及跨多个共享变量时 CAS 无效。
但是从 JDK 1.5开始,提供了AtomicReference
类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作.所以我们可以使用锁或者利用AtomicReference
类把多个共享变量合并成一个共享变量来操作。
(14.4)CAS与synchronized的使用情景
简单的来说CAS适用于写比较少的情况下(多读场景,冲突一般较少),synchronized适用于写比较多的情况下(多写场景,冲突一般较多)
- 对于资源竞争较少(线程冲突较轻)的情况,使用synchronized同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗cpu资源;而CAS基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。
- 对于资源竞争严重(线程冲突严重)的情况,CAS自旋的概率会比较大,从而浪费更多的CPU资源,效率低于synchronized。
补充: Java并发编程这个领域中synchronized关键字一直都是元老级的角色,很久之前很多人都会称它为 “重量级锁” 。但是,在JavaSE 1.6之后进行了主要包括为了减少获得锁和释放锁带来的性能消耗而引入的 偏向锁 和 轻量级锁 以及其它各种优化之后变得在某些情况下并不是那么重了。synchronized的底层实现主要依靠 Lock-Free 的队列,基本思路是 自旋后阻塞,竞争切换后继续竞争锁,稍微牺牲了公平性,但获得了高吞吐量。在线程冲突较少的情况下,可以获得和CAS类似的性能;而线程冲突严重的情况下,性能远高于CAS。
(15)其余常见多线程知识点
(15.1)为什么不能直接调用run()方法
new 一个 Thread,线程进入了新建状态;调用 start() 方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。
start() 会执行(线程的相应准备工作),然后自动执行 run() 方法的内容,这是真正的多线程工作。 而直接执行 run() 方法,会把 run 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。
总结: 调用 start 方法方可启动线程并使线程进入就绪状态,而 run 方法只是 thread 的一个普通方法调用,还是在主线程里执行。
(15.2)并发与并行的区别
- 并发: 同一时间段,多个任务都在执行 (单位时间内不一定同时执行);
- 并行: 单位时间内,多个任务同时执行。
(15.3)什么是happened-before原则?
(15.4)如何进行无锁化编程?