Java 并发
2022-08-07 11:26:58 62 举报
AI智能生成
根据 JavaGuide 整理的 Java 并发脑图
作者其他创作
大纲/内容
基础知识
进程和线程
进程
进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。
每个进程都有自己独立的一块内存空间,一个进程可以有多个线程
在 Java 中,当我们启动 main 函数时其实就是启动了一个 JVM 的进程,而 main 函数所在的线程就是这个进程中的一个线程,也称主线程。
一个 Java 程序的运行是 main 线程和多个其他线程同时运行。
Java 默认有2个线程: main线程、GC线程。
一个 Java 程序的运行是 main 线程和多个其他线程同时运行。
Java 默认有2个线程: main线程、GC线程。
线程
线程与进程相似,但线程是一个比进程更小的执行单位
与进程不同的是同类的多个线程共享进程的堆和方法区资源,但每个线程有自己的程序计数器、虚拟机栈和本地方法栈,
所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。
所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。
进程和线程的关系、区别及优缺点
从 JVM 的角度来说一下线程和进程之间的关系:
从上图可以看出:一个进程中可以有多个线程,多个线程共享进程的堆和方法区 (JDK1.8 之后的元空间)资源,
但是每个线程有自己的程序计数器、虚拟机栈 和 本地方法栈。
从上图可以看出:一个进程中可以有多个线程,多个线程共享进程的堆和方法区 (JDK1.8 之后的元空间)资源,
但是每个线程有自己的程序计数器、虚拟机栈 和 本地方法栈。
线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。
线程执行开销小,但不利于资源的管理和保护;而进程正相反。
线程执行开销小,但不利于资源的管理和保护;而进程正相反。
为什么程序计数器是私有的?
程序计数器的作用:
- 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
需要注意的是,如果执行的是 native 方法,那么程序计数器记录的是 undefined 地址,
只有执行的是 Java 代码时程序计数器记录的才是下一条指令的地址。
只有执行的是 Java 代码时程序计数器记录的才是下一条指令的地址。
所以,程序计数器私有主要是为了 线程切换后能恢复到正确的执行位置。
虚拟机栈和本地方法栈为什么是私有的?
虚拟机栈: 每个 Java 方法在执行的同时会创建一个栈帧用于 存储局部变量表、操作数栈、常量池引用等信息。
从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
本地方法栈: 和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,
而 本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
而 本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
所以,为了 保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。
堆和方法区为什么是共有的?
堆是进程中最大的一块内存,主要用于存放新创建的对象 (几乎所有对象都在这里分配内存);
方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
堆和方法区是所有线程共享的资源,所以是私有的。
方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
堆和方法区是所有线程共享的资源,所以是私有的。
并发和并行
并发:两个及两个以上的作业在同一 时间段 内执行。
- CPU 只有一核,模拟出来多条线程。可以使用 CPU 快速交替,来模拟多线程。
- 并发编程的本质:充分利用CPU的资源。
并行:两个及两个以上的作业在同一 时刻 执行。
- CPU 有多核,多个线程可以同时执行。
- 并行才是真正的同时执行。
并发编程 Bug 的源头
追根溯源如下图:
使用多线程可能带来的问题
并发编程的目的就是为了能提高程序的执行效率提高程序运行速度,但是并发编程并不总是能提高程序运行速度的,
而且并发编程可能会遇到很多问题,比如:内存泄漏、死锁、线程不安全等等。
而且并发编程可能会遇到很多问题,比如:内存泄漏、死锁、线程不安全等等。
线程的生命周期和状态
状态
Java 线程在运行的生命周期中的指定时刻只可能处于下面 6 种不同状态的其中一个状态:
生命周期
线程在生命周期中并不是固定处于某一个状态而是随着代码的执行在不同状态之间切换。Java 线程状态变迁如下图所示:
对上图的解释:
线程创建之后它将处于 NEW(新建) 状态,调用 start() 方法后开始运行,线程这时候处于 READY(可运行) 状态。可运行状态的线程获得了 CPU 时间片(timeslice)后就处于 RUNNING(运行) 状态。
当线程执行 wait()方法之后,线程进入 WAITING(等待) 状态。进入等待状态的线程需要依靠其他线程的通知才能够返回到运行状态,而 TIMED_WAITING(超时等待) 状态相当于在等待状态的基础上增加了超时限制,比如通过 sleep(long millis)方法或 wait(long millis)方法可以将 Java 线程置于 TIMED_WAITING 状态。当超时时间到达后 Java 线程将会返回到 RUNNABLE 状态。当线程调用同步方法时,在没有获取到锁的情况下,线程将会进入到 BLOCKED(阻塞) 状态。线程在执行 Runnable 的run()方法之后将会进入到 TERMINATED(终止) 状态。
线程创建之后它将处于 NEW(新建) 状态,调用 start() 方法后开始运行,线程这时候处于 READY(可运行) 状态。可运行状态的线程获得了 CPU 时间片(timeslice)后就处于 RUNNING(运行) 状态。
当线程执行 wait()方法之后,线程进入 WAITING(等待) 状态。进入等待状态的线程需要依靠其他线程的通知才能够返回到运行状态,而 TIMED_WAITING(超时等待) 状态相当于在等待状态的基础上增加了超时限制,比如通过 sleep(long millis)方法或 wait(long millis)方法可以将 Java 线程置于 TIMED_WAITING 状态。当超时时间到达后 Java 线程将会返回到 RUNNABLE 状态。当线程调用同步方法时,在没有获取到锁的情况下,线程将会进入到 BLOCKED(阻塞) 状态。线程在执行 Runnable 的run()方法之后将会进入到 TERMINATED(终止) 状态。
上下文切换
线程在执行过程中会有自己的运行条件和状态(也称上下文),比如上文所说到过的程序计数器,栈信息等。
当出现如下情况的时候,线程会从占用 CPU 状态中退出。
这其中前三种都会发生线程切换,线程切换意味着需要保存当前线程的上下文,留待线程下次占用
CPU 的时候恢复现场。并加载下一个将要占用 CPU 的线程上下文。这就是所谓的 上下文切换。
当出现如下情况的时候,线程会从占用 CPU 状态中退出。
- 主动让出 CPU,比如调用了 sleep(), wait() 等。
- 时间片用完,因为操作系统要防止一个线程或者进程长时间占用CPU导致其他线程或者进程饿死。
- 调用了阻塞类型的系统中断,比如请求 IO,线程被阻塞。
- 被终止或结束运行
这其中前三种都会发生线程切换,线程切换意味着需要保存当前线程的上下文,留待线程下次占用
CPU 的时候恢复现场。并加载下一个将要占用 CPU 的线程上下文。这就是所谓的 上下文切换。
上下文切换是现代操作系统的基本功能,因其每次需要保存信息恢复信息,这将会占用 CPU,
内存等系统资源进行处理,也就意味着效率会有一定损耗,如果频繁切换就会造成整体效率低下。
内存等系统资源进行处理,也就意味着效率会有一定损耗,如果频繁切换就会造成整体效率低下。
sleep() 方法和 wait() 方法区别和共同点
- 两者都可以暂停线程的执行。
- 两者最主要的区别在于:sleep() 方法 没有释放锁,而 wait() 方法 释放了锁 。
- wait() 通常被用于线程间交互/通信,sleep() 通常被用于暂停执行。
- wait() 方法被调用后,线程不会自动苏醒,需要 别的线程调用同一个对象 上的 notify() 或者 notifyAll() 方法。
sleep() 方法执行完成后,线程会自动苏醒。或者可以使用 wait(long timeout) 超时后线程会自动苏醒。
为什么 wait() 方法不定义在 Thread 中?
wait() 是让 获得对象锁的线程 实现等待,会自动释放当前线程占有的对象锁。每个对象都拥有对象锁,既然要释放
当前线程 占有的对象锁 并让其进入 WAITING 状态,自然是要操作对应的对象,而不是当前的线程 Thread。
当前线程 占有的对象锁 并让其进入 WAITING 状态,自然是要操作对应的对象,而不是当前的线程 Thread。
所以 wait() 方法定义在 Object 类中,使得每个对象都会有 wait()、notify() 等方法。
类似的问题:为什么 sleep() 方法定义在 Thread 中?
sleep() 是让 当前线程 暂停执行,不涉及到对象,也不需要获得对象锁,只是让线程暂停。
sleep() 是让 当前线程 暂停执行,不涉及到对象,也不需要获得对象锁,只是让线程暂停。
为什么调用 start() 方法时会执行 run() 方法,为什么不能直接调用 run() 方法?
new 一个 Thread,线程进入了新建状态。调用 start()方法,会 启动一个线程并使线程 进入了就绪状态,当分配到时间片后就可以开始运行了。
start() 会执行线程的相应准备工作,然后自动执行 run() 方法 的内容,这是真正的多线程工作。 但是,直接执行 run() 方法,会把 run() 方法
当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。
start() 会执行线程的相应准备工作,然后自动执行 run() 方法 的内容,这是真正的多线程工作。 但是,直接执行 run() 方法,会把 run() 方法
当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。
总结: 调用 start() 方法方可启动线程(真正启动线程的是 start() 方法)并使线程进入就绪状态,直接执行 run() 方法的话不会以多线程的方式执行。
什么是死锁?如何预防、避免?
死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的互相等待 的现象,
在无外力作用的情况下,这些线程会一直相互等待而无法继续运行下去。
在无外力作用的情况下,这些线程会一直相互等待而无法继续运行下去。
那么为什么会产生死锁呢? 死锁的产生必须具备以下四个条件:
- 互斥条件:该资源任意时刻只有一个线程占用;
- 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放;
- 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完后才释放资源;
- 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系;
如何预防死锁? 破坏掉任意一条死锁产生的条件即可(其中互斥条件是不能破坏的,为了线程安全,锁本来就是互斥的):
- 破坏请求与保持条件:一次性就把所有需要的资源申请了,不要在运行中再去申请别的资源;
- 破坏不剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,则主动释放自己占有的资源;
- 破坏循环等待条件:靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序。
如何避免死锁?
安全状态 指的是系统能够按照某种线程推进顺序(P1、P2、P3.....Pn)来为每个线程分配所需资源,直到
满足每个线程对资源的最大需求,使每个线程都可顺利完成。称 <P1、P2、P3.....Pn> 序列为安全序列。
- 避免死锁就是在资源分配时,借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入 安全状态。
安全状态 指的是系统能够按照某种线程推进顺序(P1、P2、P3.....Pn)来为每个线程分配所需资源,直到
满足每个线程对资源的最大需求,使每个线程都可顺利完成。称 <P1、P2、P3.....Pn> 序列为安全序列。
进阶知识
synchronized 关键字
对 synchronized 的理解
synchronized 关键字解决的是 多个线程之间访问资源的同步性,synchronized
关键字可以保证被它修饰的 方法 或者 代码块 在 任意时刻 只能有 一个线程执行。
关键字可以保证被它修饰的 方法 或者 代码块 在 任意时刻 只能有 一个线程执行。
在 Java 早期版本中,synchronized 属于 重量级锁,效率低下。
因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。
如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状
态之间的转换需要相对比较长的时间,时间成本相对较高。
因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。
如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状
态之间的转换需要相对比较长的时间,时间成本相对较高。
Java 6 之后 Java 官方对从 JVM 层面对 synchronized 较大优化,所以现在的 synchronized 锁效率也优化得很不错了。
JDK1.6 对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
所以,目前不论是各种开源框架还是 JDK 源码都大量使用了 synchronized 关键字。
JDK1.6 对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
所以,目前不论是各种开源框架还是 JDK 源码都大量使用了 synchronized 关键字。
怎么使用 synchronized
synchronized 关键字最主要的三种使用方式
1. 修饰实例方法: 作用于当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁
2. 修饰静态方法: 也就是给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁
因为 静态成员不属于任何一个实例对象,是类成员( static 表明这是该类的一个静态资源,不管 new 了多少个对象,只有一份)。
所以,如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,
是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而 访问非静态 synchronized 方法占用的锁是当前实例对象锁。
因为 静态成员不属于任何一个实例对象,是类成员( static 表明这是该类的一个静态资源,不管 new 了多少个对象,只有一份)。
所以,如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,
是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而 访问非静态 synchronized 方法占用的锁是当前实例对象锁。
3. 修饰代码块 :指定加锁对象,对给定对象/类加锁
- synchronized(this|object) 表示进入同步代码库前要获得 给定对象的锁
- synchronized(类.class) 表示进入同步代码前要获得 当前 class 的锁
总结
synchronized 关键字加到 static 静态方法 和 synchronized(.class) 代码块 上都是是给 Class 类上锁。
synchronized 关键字加到 实例方法 上是给 对象实例上锁。
尽量不要使用 synchronized(String a) ,因为 JVM 中,字符串常量池具有缓存功能!
构造方法不能使用 synchronized 关键字修饰。
构造方法本身就属于线程安全的,不存在同步的构造方法一说。
构造方法本身就属于线程安全的,不存在同步的构造方法一说。
synchronized 底层原理
synchronized 关键字底层原理属于 JVM 层面。
在 Java 中每个对象都隐式包含一个 monitor(监视器)对象,加锁的过程其实就是竞争 monitor 的过程,
当线程进入字节码 monitorenter 指令之后,线程将持有 monitor 对象,执行 monitorexit 时释放 monitor
对象,当其他线程没有拿到 monitor 对象时,则需要阻塞等待获取该对象。
当线程进入字节码 monitorenter 指令之后,线程将持有 monitor 对象,执行 monitorexit 时释放 monitor
对象,当其他线程没有拿到 monitor 对象时,则需要阻塞等待获取该对象。
而可重入的实现则是维护一个计数器,计数器为 0 才表示可以竞争到锁。
synchronized 同步代码块 的情况:
通过 JDK 自带的 javap 命令查看 SynchronizedDemo 类反编译后的相关字节码信息
从上面可以看出:synchronized 同步语句块 的实现使用的是 monitorenter 和 monitorexit 指令:
- monitorenter 指令指向同步代码块的开始位置
- monitorexit 指令则指明同步代码块的结束位置
当执行 monitorenter 指令时,线程试图获取锁也就是获取 对象监视器 monitor 的持有权。
当执行 monitorexit 指令时,线程试图释放锁也就是释放 对象监视器 monitor 的持有权。
当执行 monitorexit 指令时,线程试图释放锁也就是释放 对象监视器 monitor 的持有权。
在执行monitorenter时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。
如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。
如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。
对象锁的的拥有者线程才可以执行 monitorexit 指令来释放锁。在执行 monitorexit 指令后,将锁计数器设为 0,表明锁被释放,其他线程可以尝试获取锁。
synchronized 修饰 方法 的的情况:
可以看到在方法的 flags 里面多了一个 ACC_SYNCHRONIZED 标志,这标志用来告诉 JVM 这是一个同步方法,
在 进入该方法之前先获取相应的锁,锁的计数器 +1,方法结束后计数器 -1,如果获取失败就阻塞住,直到该锁被释放。
在 进入该方法之前先获取相应的锁,锁的计数器 +1,方法结束后计数器 -1,如果获取失败就阻塞住,直到该锁被释放。
如果是实例方法,JVM 会尝试获取实例对象的锁。如果是静态方法,JVM 会尝试获取当前 class 的锁。
总结
synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,
其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。
其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。
synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,
取得代之的是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。
取得代之的是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。
不过两者的本质都是对对象监视器 monitor 的获取。
JDK 1.6 对锁做了哪些优化
自适应自旋锁
JDK 1.5 在升级为 JDK 1.6 时,HotSpot 虚拟机团队在锁的优化上下了很大功夫,比如实现了自适应式自旋锁、锁升级等。
JDK 1.6 引入了 自适应式自旋锁 意味着自旋的时间不再是固定的时间了,比如在同一个锁对象上,如果通过自旋等待成功获取了锁,
那么虚拟机就会认为,它下一次很有可能也会成功 (通过自旋获取到锁),因此 允许自旋等待的时间会相对的比较⻓。
那么虚拟机就会认为,它下一次很有可能也会成功 (通过自旋获取到锁),因此 允许自旋等待的时间会相对的比较⻓。
而当 某个锁通过自旋很少成功,那么以后在获取该锁时,可能会直接忽略掉自旋的过程,
让其直接进入阻塞状态,以 避免浪费 CPU 的资源,这就是自适应自旋锁的功能。
让其直接进入阻塞状态,以 避免浪费 CPU 的资源,这就是自适应自旋锁的功能。
注意:是这个锁自旋很少成功,可以理解为很少获得 CPU 的使用权。
锁升级
锁升级其实就是从偏向锁到轻量级锁再到重量级锁升级的过程,这是 JDK 1.6 提供的优化功能,也称之为锁膨胀。
偏向锁 是指在 无竞争的情况下 设置的一种锁状态。偏向锁的意思是它会 偏向于第一个获取它的线程,当锁对象第一
次被获取到之后,会在此对象头中设置标示为“01”,表示偏向锁的模式,并且在对象头中记录此线程的 ID,这种
情况下,如果是 持有偏向锁的线程每次在进入的话,不再进行任何同步操作,如 Locking、Unlocking 等,直到 另一
个线程尝试获取此锁的时候,偏向锁模式才会结束,偏向锁可以提高带有同步但无竞争的程序性能。
次被获取到之后,会在此对象头中设置标示为“01”,表示偏向锁的模式,并且在对象头中记录此线程的 ID,这种
情况下,如果是 持有偏向锁的线程每次在进入的话,不再进行任何同步操作,如 Locking、Unlocking 等,直到 另一
个线程尝试获取此锁的时候,偏向锁模式才会结束,偏向锁可以提高带有同步但无竞争的程序性能。
但如果 在多数锁总会被不同的线程访问时,偏向锁模式就比较多余了,此时可以通过 -XX:-UseBiasedLocking 来禁用偏向锁以提高性能。
轻量锁是相对于重量锁而言的,在 JDK 1.6 之前,synchronized 是通过 操作系统的互斥量(mutex lock)来实现的,
这种实现方式需要 在用户态和核心态之间做转换,有很大的性能消耗,这种传统实现锁的方式被称之为 重量锁。
这种实现方式需要 在用户态和核心态之间做转换,有很大的性能消耗,这种传统实现锁的方式被称之为 重量锁。
而 轻量锁 是通过 比较并交换(CAS,Compare and Swap)来实现的,它对比的是线程和对象的 Mark Word(对象
头中的一个区域),如果更新成功则表示当前线程成功拥有此锁;如果失败,虚拟机会先检查对象的 Mark Word
是否指向当前线程的栈帧,如果是,则说明当前线程已经拥有此锁,否则,则说明此锁已经被其他线程占用了。当
两个以上的线程争抢此锁时,轻量级锁就膨胀为重量级锁,这就是锁升级的过程,也是 JDK 1.6 锁优化的内容。
头中的一个区域),如果更新成功则表示当前线程成功拥有此锁;如果失败,虚拟机会先检查对象的 Mark Word
是否指向当前线程的栈帧,如果是,则说明当前线程已经拥有此锁,否则,则说明此锁已经被其他线程占用了。当
两个以上的线程争抢此锁时,轻量级锁就膨胀为重量级锁,这就是锁升级的过程,也是 JDK 1.6 锁优化的内容。
可重入锁的好处
避免死锁
假设有一个普通方法都被 synchronized 修饰,在这个方法内也调用了一个 被 synchronized 修饰的方法,
相当于递归调用,这意味着执行这两个方法都需要先获取对应的对象锁。
相当于递归调用,这意味着执行这两个方法都需要先获取对应的对象锁。
如果一个线程需要执行第一个方法,当执行第一个方法时获取锁成功,当调用第二个方法时,
如果 synchronized 不可重入,那么会在方法二阻塞,而又要执行完第一个方法才会释放锁,所以形成了死锁。
如果 synchronized 不可重入,那么会在方法二阻塞,而又要执行完第一个方法才会释放锁,所以形成了死锁。
提升封装性
想想看,如果没有可重入锁,使用普通的锁怎么实现递归调用,在调用前先解锁,
然后马上又要加锁进入递归,就需要频繁的加锁解锁加锁解锁。。。
然后马上又要加锁进入递归,就需要频繁的加锁解锁加锁解锁。。。
有了可重入锁,我们可以直接调用,可重入锁会自动帮我们计算重入了多少次,计数器清零时即代表释放完了锁。
synchronized 可见性、有序性、可重入性怎么实现?
怎么保证可见性?
可见性是跟 JMM 有关系的。
- 线程 加锁前,将 清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值。
- 线程 加锁后,其它线程 无法获取主内存中的共享变量。
- 线程 解锁前,必须 把共享变量的最新值刷新到主内存中。
怎么保证有序性?
synchronized 同步的代码块,具有 排他性,一次只能被一个线程拥有,所以 synchronized 保证同一时刻,代码是单线程执行的。
synchronized 保证的有序是 执行结果的有序性,而 不是防止指令重排的有序性。
怎么实现可重入?
synchronized 锁对象的时候 有个计数器,JVM 会记录下线程获取锁的次数,
在执行完对应的代码块之后,计数器就会 -1,直到计数器清零,就释放锁了。
在执行完对应的代码块之后,计数器就会 -1,直到计数器清零,就释放锁了。
synchronized 和 ReentrantLock 的区别
两者都是可重入锁
“可重入锁”指的是 自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁
还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的;如果是不可重入锁的话,就会造成死锁。
同一个线程每次获取锁,锁的计数器都自增 1,所以要等到锁的计数器下降为 0 时才能释放锁。
还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的;如果是不可重入锁的话,就会造成死锁。
同一个线程每次获取锁,锁的计数器都自增 1,所以要等到锁的计数器下降为 0 时才能释放锁。
synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API
synchronized 是依赖于 JVM 实现的,前面也讲到了 虚拟机团队在 JDK1.6 为 synchronized
关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。
关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。
ReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成),
所以我们可以通过查看它的源代码,来看它是如何实现的。
所以我们可以通过查看它的源代码,来看它是如何实现的。
ReentrantLock 比 synchronized 增加了一些高级功能
等待可中断 : ReentrantLock 提供了一种能够中断等待锁的线程的机制,通过 lock.lockInterruptibly() 来实现这个机制。
也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。
也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。
可实现公平锁 : ReentrantLock 可以指定是公平锁还是非公平锁。而 synchronized 只能是非公平锁。公平锁就是先等待的线程先获得锁。
ReentrantLock 默认情况是非公平的,可以通过 ReentrantLock 类的 ReentrantLock(boolean fair) 构造方法来制定是否是公平的。
ReentrantLock 默认情况是非公平的,可以通过 ReentrantLock 类的 ReentrantLock(boolean fair) 构造方法来制定是否是公平的。
可实现选择性通知(锁可以绑定多个条件): synchronized 关键字与 wait() 和 notify()/notifyAll() 方法相结合可以实现等待/通知机制。
ReentrantLock 类当然也可以实现,但是需要借助于 Condition 接口与 newCondition() 方法,等待唤醒更加灵活,能指定唤醒。
ReentrantLock 类当然也可以实现,但是需要借助于 Condition 接口与 newCondition() 方法,等待唤醒更加灵活,能指定唤醒。
指定唤醒示例:
如果你想使用上述功能,那么选择 ReentrantLock 是一个不错的选择。性能已不是选择标准
JMM
JMM 介绍
Java 内存模型主要由以下三部分构成:1 个主内存、n 个线程、n 个工作内存(与线程一一对应),数据就在它们三者之间来回倒腾。
那么怎么倒腾呢?靠的是 Java 提供给我们的 8 个原子操作:lock、unlock、read、load、use、assign、store、write,
其操作流程示意图如下:
其操作流程示意图如下:
一个变量从主内存拷贝到工作内存,再从工作内存同步回主内存的流程为:
|主内存| -> read -> load -> |工作内存| -> use -> |Java线程| -> assign -> |工作内存| -> store -> write -> |主内存|
|主内存| -> read -> load -> |工作内存| -> use -> |Java线程| -> assign -> |工作内存| -> store -> write -> |主内存|
Java 内存模型中的 8 个原子操作:
lock(锁定)
:作用于主内存,把一个变量标识为一个线程独占状态。unlock(解锁)
:作用于主内存,释放一个处于锁定状态的变量。read(读取)
:作用于主内存,把一个变量的值从主内存传输到线程工作内存中,供之后的load
操作使用。load(载入)
:作用于工作内存,把read
操作从主内存中得到的变量值放入工作内存的变量副本中。use(使用)
:作用于工作内存,把工作内存中的一个变量传递给执行引擎,虚拟机遇到使用变量值的字节码指令时会执行。assign(赋值)
:作用于工作内存,把一个从执行引擎得到的值赋给工作内存的变量,虚拟机遇到给变量赋值的字节码指令时会执行。store(存储)
:作用于工作内存,把工作内存中的一个变量传送到主内存中,供之后的write
操作使用。write(写入)
:作用于主内存,把store
操作从工作内存中得到的变量值存入主内存的变量中。
通过上图可以发现,Java 线程只能操作自己的工作内存,其对变量的所有操作(读取、赋值等)
都必须在工作内存中进行,不能直接读写主内存中的变量。这就有可能会 导致可见性问题:
都必须在工作内存中进行,不能直接读写主内存中的变量。这就有可能会 导致可见性问题:
- 因为对于主内存中的变量 A,其在不同的线程的工作内存中可能存在不同的副本 A1、A2、A3。
- 不同线程的
read
和load
、store
和write
不一定是连续执行的,中间可以插入其他命令。Java 只能保证read
和load
、store
和write
的执行 对于一个线程而言是连续的,但是并不保证不同线程的read
和load
、store
和write
的执行是连续的,如下图:
假设有两个线程 A 和 B,其中线程 A 在写入共享变量,线程 B 要读取共享变量,我们想让线程 A 先完成写入,线程 B 再完成读取。此时即便我们是按照 “线程 A 写入 -> 线程 B 读取” 的顺序开始执行的,真实的执行顺序也可能是这样的:
storeA -> readB -> writeA -> loadB
,这将导致线程 B 读取的是变量的旧值,而非线程 A 修改过的新值。也就是说,线程 A 修改变量的执行先于线程 B 操作了,但这个操作对于线程 B 而言依旧是不可见的。 那么如何解决这个问题呢?通过上述的分析可以发现,可见性问题的本身,也是由于不同线程之间的执行顺序得不到保证导致的,
因此我们也可以将它的解决和有序性合并,即 对 Java 一些指令的操作顺序进行限制,这样既保证了有序性,有解决了可见性。
因此我们也可以将它的解决和有序性合并,即 对 Java 一些指令的操作顺序进行限制,这样既保证了有序性,有解决了可见性。
于是乎,Java 给出了一些命令执行的顺序规范,也就是大名鼎鼎 Happens-Before 规则。(可见性的底层实现在下面 volatile 中)
Happens-Before 规则
根据语义,Happens-Before,就是 即便是对于不同的线程,前面的操作也应该发生在后面操作的前面,
也就是说,Happens-Before 规则保证:前面的操作的结果对后面的操作一定是可见的。
也就是说,Happens-Before 规则保证:前面的操作的结果对后面的操作一定是可见的。
Happens-Before 规则本质上是一种 顺序约束规范,用来约束编译器的优化行为。就是说,为了执行效率,
允许编译器的优化行为,但是为了保证程序运行的正确性,我们要求编译器优化后需要满足 Happens-Before 规则。
允许编译器的优化行为,但是为了保证程序运行的正确性,我们要求编译器优化后需要满足 Happens-Before 规则。
与程序员密切相关的 Happens-Before 规则如下:
程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作。意思就是在一个线程中程序要有序的执行。
监视器锁规则:对一个锁的解锁,happens-before 于随后对这个锁的加锁。意思就是要先释放锁,才能进行加锁。
volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。
意思就是写操作执行完之后才能进行读操作,这是 volatile 保证可见性的依据之一。
传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作。意思就是在一个线程中程序要有序的执行。
监视器锁规则:对一个锁的解锁,happens-before 于随后对这个锁的加锁。意思就是要先释放锁,才能进行加锁。
volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。
意思就是写操作执行完之后才能进行读操作,这是 volatile 保证可见性的依据之一。
传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
JMM 中的内存屏障
介绍
内存屏障可以禁止特定类型处理器的重排序,从而让程序按我们预想的流程去执行。
内存屏障,又称内存栅栏,是一个 CPU 指令,基本上它是一条这样的指令:
内存屏障,又称内存栅栏,是一个 CPU 指令,基本上它是一条这样的指令:
- 保证特定操作的执行顺序。
- 影响某些数据(或则是某条指令的执行结果)的内存 可见性。
编译器和 CPU 能够重排序指令,在保证最终结果不变的情况下,通过指令重排来尝试优化性能。
插入一条 Memory Barrier 会告诉编译器和 CPU:不管什么指令都不能和这条 Memory Barrier 指令重排序。
插入一条 Memory Barrier 会告诉编译器和 CPU:不管什么指令都不能和这条 Memory Barrier 指令重排序。
Memory Barrier 所做的另外一件事是 强制刷出各种 CPU Cache,如一个 Write-Barrier(写入屏障)将
刷出所有在 Barrier 之前写入 Cache 的数据,因此,任何 CPU 上的线程都能读取到这些数据的最新版本。
刷出所有在 Barrier 之前写入 Cache 的数据,因此,任何 CPU 上的线程都能读取到这些数据的最新版本。
JMM 提供的 4 种内存屏障指令
JMM为了屏蔽了这种底层硬件平台的差异,提供了四类内存屏障指令,来为不同的硬件架构生成相应的内存屏障的机器码
volatile 关键字
CPU 缓存模型
为什么要使用 CPU 高速缓存?
类比我们开发网站后台系统使用的缓存(比如 Redis)是为了解决程序处理速度和访问常规关系型数据库速度不对等的问题。
CPU 缓存则是为了解决 CPU 处理速度和内存处理速度不对等的问题。
CPU 缓存则是为了解决 CPU 处理速度和内存处理速度不对等的问题。
我们甚至可以把 内存可以看作外存的高速缓存,程序运行的时候我们把外存的数据复制到内存,
由于内存的处理速度远远高于外存,这样提高了处理速度。
由于内存的处理速度远远高于外存,这样提高了处理速度。
CPU Cache 缓存的是内存数据用于解决 CPU 处理速度和内存不匹配的问题,
内存缓存的是硬盘数据用于解决硬盘访问速度过慢的问题。
内存缓存的是硬盘数据用于解决硬盘访问速度过慢的问题。
CPU Cache 的工作方式
先复制一份数据到 CPU Cache 中,当 CPU 需要用到的时候就可以直接从 CPU Cache 中读取数据,
当运算完成后,再将运算得到的数据写回 Main Memory 中。但是,这样存在 内存缓存不一致性的问题 !
比如我执行一个 i++操作的话,如果两个线程同时执行的话,假设两个线程从 CPU Cache 中读取的 i=1,
两个线程做了 1++运算完之后再写回 Main Memory 之后 i=2,而正确结果应该是 i=3。
当运算完成后,再将运算得到的数据写回 Main Memory 中。但是,这样存在 内存缓存不一致性的问题 !
比如我执行一个 i++操作的话,如果两个线程同时执行的话,假设两个线程从 CPU Cache 中读取的 i=1,
两个线程做了 1++运算完之后再写回 Main Memory 之后 i=2,而正确结果应该是 i=3。
CPU 为了解决内存缓存不一致性问题可以通过制定缓存一致协议或者其他手段来解决。
JMM(Java 内存模型)
Java 内存模型抽象了线程和主内存之间的关系,就比如说 线程之间的共享变量必须存储在主内存中。
Java 内存模型主要目的是为了屏蔽系统和硬件的差异,避免一套代码在不同的平台下产生的效果不一致。
Java 内存模型主要目的是为了屏蔽系统和硬件的差异,避免一套代码在不同的平台下产生的效果不一致。
在 JDK1.2 之前,Java 的内存模型实现总是 从主存(即共享内存)读取变量,是不需要进行特别的注意的。
而在当前的 Java 内存模型下,线程可以把变量保存在本地内存(比如机器的寄存器)中,而 不是直接在主存中进行读写。
这就可能造成 一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。
而在当前的 Java 内存模型下,线程可以把变量保存在本地内存(比如机器的寄存器)中,而 不是直接在主存中进行读写。
这就可能造成 一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。
要解决这个问题,就需要把变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,
每次使用它都要到主存中进行读取最新值,然后 load 回本地内存,再供线程使用。
所以,volatile 关键字 除了防止 JVM 的指令重排 ,还有一个重要的作用就是 保证变量的可见性。
每次使用它都要到主存中进行读取最新值,然后 load 回本地内存,再供线程使用。
所以,volatile 关键字 除了防止 JVM 的指令重排 ,还有一个重要的作用就是 保证变量的可见性。
并发编程的三个重要特性
原子性 : 一次操作或者多次操作,要么所有的操作全部都得到执行并且不会受到任何因素的干扰而中断,要么都不执行。
synchronized 可以保证代码片段的原子性,volatile 不保证原子性。
synchronized 可以保证代码片段的原子性,volatile 不保证原子性。
可见性 :当一个线程对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。
volatile 保证共享变量的可见性。
volatile 保证共享变量的可见性。
有序性 :代码在执行的过程中的先后顺序,Java 在编译器以及运行期间的优化,代码的执行顺序未必就是编写代码时候的顺序。
volatile 禁止指令进行重排序优化。
volatile 禁止指令进行重排序优化。
底层原理
保证可见性
上面也说到,在 JMM 中每个线程都有自己的本地内存,线程对于变量的读写都要在本地内存中进行,
而不能直接读写主存中的变量。同时,线程之间的本地内存不共享,不能直接互相访问,需要通过主存来完成。
而不能直接读写主存中的变量。同时,线程之间的本地内存不共享,不能直接互相访问,需要通过主存来完成。
误解:如果一个变量被标记成了 volatile 变量,那么这个变量的值就 不会被加载进线程的工作内存中,而是直接在主内存上进行读写。
实际上并不是这样的,因为这样我们需要为 volatile 变量的读写设置一套特殊的规则,这显然是不合适。
即使是 volatile 变量,也是从工作内存中读取的,只是它 有特殊的操作顺序规定,使得看起来像是直接在主内存中读写。
实际上并不是这样的,因为这样我们需要为 volatile 变量的读写设置一套特殊的规则,这显然是不合适。
即使是 volatile 变量,也是从工作内存中读取的,只是它 有特殊的操作顺序规定,使得看起来像是直接在主内存中读写。
Happens-Before 规则(在上面 JMM 中) 中要求,对 volatile 变量的写操作必须在对该变量的读操作之前执行。
这个规则听起来很容易,那实际上是如何实现的呢?解决方法分两步:
这个规则听起来很容易,那实际上是如何实现的呢?解决方法分两步:
- 保证动作发生;
- 保证动作按正确的顺序发生。
1. 保证动作发生:
首先,在对 volatile 变量进行 读取和写入操作时,必须去主内存拉取新值,或者将最新值更新进主内存。
不能只更新进工作内存而不将操作同步进主内存。即在执行 read、load、use、assign、store、write 操作时:
现在,我们已经保证了将变量的最新值时刻同步进主内存的动作发生了,接下来需要 保证这个动作对于不同线程,
满足 volatile 变量的 happens-before 规则:对变量的写操作必须在对该变量的读操作之前执行。
首先,在对 volatile 变量进行 读取和写入操作时,必须去主内存拉取新值,或者将最新值更新进主内存。
不能只更新进工作内存而不将操作同步进主内存。即在执行 read、load、use、assign、store、write 操作时:
- use 操作必须与 load、read 操作同时出现,不能只 use,不 load、read:use <- load <- read;
- assign 操作必须与 store、write 操作同时出现,不能只 assign,不 store、write:assign -> store -> write;
现在,我们已经保证了将变量的最新值时刻同步进主内存的动作发生了,接下来需要 保证这个动作对于不同线程,
满足 volatile 变量的 happens-before 规则:对变量的写操作必须在对该变量的读操作之前执行。
2. 保证动作按正确的顺序发生:
其实,导致这个执行顺序出现问题的主要原因在于,这个 读写 volatile 变量的操作不是原子的。
无论是读还是写,它都 分成了 3 个命令(use <- load <- read 或 assign -> store -> write),
若出现线程A 的 write 操作发生在线程B 的 use 操作之后,这时线程B 在主存中读取到的数据就是旧数据。
所以,在进行读操作时,必须等写操作把 3 个命令都执行完后(3 个命令执行完才是真正的写完成),才能进行读。
这样即使是在不同线程间,对这个变量的访问也能保证可见性,即保证访问到的变量值是最新的。
其实,导致这个执行顺序出现问题的主要原因在于,这个 读写 volatile 变量的操作不是原子的。
无论是读还是写,它都 分成了 3 个命令(use <- load <- read 或 assign -> store -> write),
若出现线程A 的 write 操作发生在线程B 的 use 操作之后,这时线程B 在主存中读取到的数据就是旧数据。
所以,在进行读操作时,必须等写操作把 3 个命令都执行完后(3 个命令执行完才是真正的写完成),才能进行读。
这样即使是在不同线程间,对这个变量的访问也能保证可见性,即保证访问到的变量值是最新的。
那么 Java 是如何利用现有的工具,实现了上述的两个效果的呢?
通过 观察对 volatile 变量的赋值操作的字节码反编译后,发现在执行了变量赋值操作之后,额外加了一个 lock 前缀指令。
lock 指令会将 当前 CPU 的 Cache 写入主内存,并无效化其他 CPU 的 Cache,相当于执行 assign 后,又进行了 store -> write
这使得 其他 CPU 可以立即看见 volatile 变量的修改。因为其他 CPU 在读取 volatile 变量时,
会发现缓存已过期,于是会去主存中拉取最新的 volatile 变量,也就被迫在 use 前进行了一次 read -> load。
会发现缓存已过期,于是会去主存中拉取最新的 volatile 变量,也就被迫在 use 前进行了一次 read -> load。
lock 后就是一个原子操作。原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,
就一直运行到结束,中间不会有任何 context switch (切换到另一个线程)。
就一直运行到结束,中间不会有任何 context switch (切换到另一个线程)。
lock 前缀指令其实就相当于一个内存屏障。内存屏障是一组 CPU 处理指令,
用来实现对内存操作的顺序限制。使得在重排序时,不能把后面的指令排在内存屏障之前。
用来实现对内存操作的顺序限制。使得在重排序时,不能把后面的指令排在内存屏障之前。
禁止指令重排
volatile 是通过编译器在生成字节码时,在指令序列中添加“内存屏障”来禁止指令重排序的。
Java编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。
为了实现volatile内存语义,JMM 针对编译器制定的 volatile 重排序规则表:
从上图可以看出:
- 当第二个操作是 volatile 写时,不管第一个操作是什么,都不能重排序。
这个规则确保 volatile 写之前的操作不会被编译器重排序到 volatile 写之后。 - 当第一个操作是 volatile 读时,不管第二个操作是什么,都不能重排序。
这个规则确保 volatile 读之后的操作不会被编译器重排序到 volatile 读之前。 - 当第一个操作是 volatile 写,第二个操作是 volatile 读或写时,不能重排序。
对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。
为此,JMM 采取保守策略。下面是基于保守策略(能加屏障就都加)的 JMM 内存屏障插入策略:
- 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。禁止上面的普通写和下面的 volatile 写重排序。
- 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。防止上面的 volatile 写与下面可能有的 volatile 读/写重排序。
- 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。禁止上面的 volatile 读和下面所有的普通读操作重排序。
- 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。禁止上面的 volatile 读和下面所有的普通写操作重排序。
上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能得到正确的volatile内存语义。
volatile 为什么不保证原子性?
一个变量简单的读取和赋值操作是原子性的,将一个变量赋值给另外一个变量不是原子性的。
Java 内存模型(JMM)仅仅保障了变量的基本 读取 和 赋值 操作是原子性的,其他均不会保证的。如果想要使某段代码块
要求具备原子性,就需要使用 synchronized 关键字、并发包中的 Lock 锁、并发包中 Atomic 各种类型的原子类来实现。
要求具备原子性,就需要使用 synchronized 关键字、并发包中的 Lock 锁、并发包中 Atomic 各种类型的原子类来实现。
所以,Java 中只有对 基本类型变量的赋值和读取是原子操作,如 i = 1 的赋值操作。但是像 j = i 或者 i++ 这样的操作都 不是原子操作,
因为他们都进行了多次原子操作,比如先读取 i 的值,再将 i 的值赋值给 j,两个原子操作加起来就不是原子操作了。
因为他们都进行了多次原子操作,比如先读取 i 的值,再将 i 的值赋值给 j,两个原子操作加起来就不是原子操作了。
synchronized 关键字和 volatile 关键字的区别
synchronized 关键字和 volatile 关键字是两个互补的存在,而不是对立的存在
volatile 关键字是线程同步的轻量级实现,所以 volatile 性能肯定比 synchronized 好 。
但是 volatile 关键字 只能用于变量 而 synchronized 可以 修饰方法 以及 代码块 。
但是 volatile 关键字 只能用于变量 而 synchronized 可以 修饰方法 以及 代码块 。
volatile 保证数据的可见性,可以 禁止指令重排,但 不能保证数据的原子性;
synchronized 关键字能保证可见性和原子性,但不能禁止指令重排。
因此 synchronized 和 volatile 通常配合起来使用,保证并发的安全。
synchronized 关键字能保证可见性和原子性,但不能禁止指令重排。
因此 synchronized 和 volatile 通常配合起来使用,保证并发的安全。
volatile 关键字主要用于解决 变量在多个线程之间的可见性,
而 synchronized 关键字解决的是 多个线程之间访问资源的同步性。
而 synchronized 关键字解决的是 多个线程之间访问资源的同步性。
ThreadLocal
简介
通常情况下,我们创建的变量是可以被任何一个线程访问并修改的。如果想实现每一个线程都有自己的专属本地变量该如何解决呢?
JDK 中提供的 ThreadLocal 类正是为了解决这样的问题。 ThreadLocal 类主要解决的就是让每个线程绑定自己的值,
可以将 ThreadLocal 类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据。
JDK 中提供的 ThreadLocal 类正是为了解决这样的问题。 ThreadLocal 类主要解决的就是让每个线程绑定自己的值,
可以将 ThreadLocal 类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据。
如果你创建了一个 ThreadLocal 变量,那么访问这个变量的每个线程都会有这个变量的本地副本,这也是 ThreadLocal 变量名的由来。
他们可以使用 get() 和 set() 方法来获取默认值或将其值更改为当前线程所存的副本的值,从而避免了线程安全问题。
ThreadLocal 就是用来避免两个线程竞争的。
他们可以使用 get() 和 set() 方法来获取默认值或将其值更改为当前线程所存的副本的值,从而避免了线程安全问题。
ThreadLocal 就是用来避免两个线程竞争的。
示例
输出:
Thread Name = 0 default formatter = yyyyMMdd HHmm
Thread Name = 0 changed formatter = yy-M-d ah:mm
Thread Name = 1 default formatter = yyyyMMdd HHmm
Thread Name = 1 changed formatter = yy-M-d ah:mm
Thread Name = 2 default formatter = yyyyMMdd HHmm
Thread Name = 2 changed formatter = yy-M-d ah:mm
Thread Name = 3 default formatter = yyyyMMdd HHmm
Thread Name = 4 default formatter = yyyyMMdd HHmm
Thread Name = 4 changed formatter = yy-M-d ah:mm
Thread Name = 3 changed formatter = yy-M-d ah:mm
从输出中可以看出,Thread-0 已经改变了 formatter 的值,但 thread-2 的 formatter 仍和初始化的值相同,其他线程也一样。
Thread Name = 0 default formatter = yyyyMMdd HHmm
Thread Name = 0 changed formatter = yy-M-d ah:mm
Thread Name = 1 default formatter = yyyyMMdd HHmm
Thread Name = 1 changed formatter = yy-M-d ah:mm
Thread Name = 2 default formatter = yyyyMMdd HHmm
Thread Name = 2 changed formatter = yy-M-d ah:mm
Thread Name = 3 default formatter = yyyyMMdd HHmm
Thread Name = 4 default formatter = yyyyMMdd HHmm
Thread Name = 4 changed formatter = yy-M-d ah:mm
Thread Name = 3 changed formatter = yy-M-d ah:mm
从输出中可以看出,Thread-0 已经改变了 formatter 的值,但 thread-2 的 formatter 仍和初始化的值相同,其他线程也一样。
上面有一段代码用到了创建 ThreadLocal 变量的那段代码用到了 Java8 Lambda 的知识,它等于下面这段代码,如果写了下面这段代码的话,
IDEA 会提示你转换为 Java8 的格式。因为 ThreadLocal 类在 Java 8 中扩展,使用一个新的方法 withInitial(),将 Supplier 功能接口作为参数。
IDEA 会提示你转换为 Java8 的格式。因为 ThreadLocal 类在 Java 8 中扩展,使用一个新的方法 withInitial(),将 Supplier 功能接口作为参数。
原理
从 Thread 类源代码入手,Thread 类中有一个 threadLocals 和 一个 inheritableThreadLocals 变量,它们都是 ThreadLocalMap 类型的变量,
我们可以把 ThreadLocalMap 理解为 ThreadLocal 类实现的定制化的 HashMap。默认情况下这两个变量都是 null,只有当前线程调用
ThreadLocal 类的 set 或 get 方法时才创建它们,实际上调用这两个方法的时候,我们调用的是 ThreadLocalMap 类对应的 get()、set() 方法。
我们可以把 ThreadLocalMap 理解为 ThreadLocal 类实现的定制化的 HashMap。默认情况下这两个变量都是 null,只有当前线程调用
ThreadLocal 类的 set 或 get 方法时才创建它们,实际上调用这两个方法的时候,我们调用的是 ThreadLocalMap 类对应的 get()、set() 方法。
ThreadLocal 类的 set() 方法如上。至此我们可以猜测:
最终的变量是在当前线程的 ThreadLocalMap 中,并不是存在 ThreadLocal 上,ThreadLocal 可以理解为是 ThreadLocalMap 的封装,传递了变量值。
ThrealLocal 类中可以通过 Thread.currentThread() 获取到当前线程对象后,直接通过 getMap(Thread t) 可以访问到该线程的 ThreadLocalMap 对象。
最终的变量是在当前线程的 ThreadLocalMap 中,并不是存在 ThreadLocal 上,ThreadLocal 可以理解为是 ThreadLocalMap 的封装,传递了变量值。
ThrealLocal 类中可以通过 Thread.currentThread() 获取到当前线程对象后,直接通过 getMap(Thread t) 可以访问到该线程的 ThreadLocalMap 对象。
每个 Thread 中都具备一个 ThreadLocalMap,而 ThreadLocalMap 可以存储以 ThreadLocal 为 key ,Object 对象为 value 的键值对。
比如我们在同一个线程中声明了两个 ThreadLocal 对象的话, Thread 内部都是使用仅有的那个 ThreadLocalMap 存放数据的,
ThreadLocalMap 的 key 就是 ThreadLocal 对象,value 就是 ThreadLocal 对象调用 set方法 设置的值。
ThreadLocalMap 的 key 就是 ThreadLocal 对象,value 就是 ThreadLocal 对象调用 set方法 设置的值。
ThreadLocalMap 是 ThreadLocal 的静态内部类。
内存泄漏问题
ThreadLocalMap 中使用的 key 为 ThreadLocal 的 弱引用,而 value 是 强引用。所以,如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。这样一来,ThreadLocalMap 中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远
无法被 GC 回收,这个时候就可能会产生内存泄露。ThreadLocalMap 实现中已经考虑了这种情况,在调用 set()、get()、remove() 方法的时候,会清理
掉 key 为 null 的记录。使用完 ThreadLocal 方法后 最好手动调用 remove() 方法
无法被 GC 回收,这个时候就可能会产生内存泄露。ThreadLocalMap 实现中已经考虑了这种情况,在调用 set()、get()、remove() 方法的时候,会清理
掉 key 为 null 的记录。使用完 ThreadLocal 方法后 最好手动调用 remove() 方法
四种引用
强引用是默认支持,当内存不足的时候,JVM开始垃圾回收,对于强引用的对象,就算是出现了OOM也不会回收对象。
软引用是一种相对强引用弱化了一些的引用,用 java.lang.ref.SoftReference 实现,可以让对象豁免一些垃圾收集。
当系统内存充足的时候,不会被回收;当系统内存不足的时候,会被回收。
当系统内存充足的时候,不会被回收;当系统内存不足的时候,会被回收。
弱引用需要用 java.lang.ref.WeakReference 实现,它比软引用的生存期更短,对于弱引用的对象来说,
只要垃圾回收机制一运行,不管 JVM 的内存空间是否够,都会回收该对象的占用内存。
不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。
弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,
Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。
只要垃圾回收机制一运行,不管 JVM 的内存空间是否够,都会回收该对象的占用内存。
不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。
弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,
Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。
虚引用要通过 java.lang.ref.PhantomReference 类来实现,虚引用不会决定对象的生命周期,如果一个对象只有虚引用,
就相当于没有引用,在任何时候都可能会被垃圾回收器回收。它不能单独使用也不能访问对象,虚引用必须和引用队列联合使用。
就相当于没有引用,在任何时候都可能会被垃圾回收器回收。它不能单独使用也不能访问对象,虚引用必须和引用队列联合使用。
详解在下面
线程池
使用线程池的好处
降低资源消耗:通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
提高响应速度:当任务到达时,任务可以不需要等到线程创建就能立即执行。
提高线程的可管理性:线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
实现 Runnable 和 Callable 的区别
Runnable 自 Java 1.0 以来一直存在,但 Callable 仅在 Java 1.5 中引入,目的就是为了来处理 Runnable 不支持的用例。
Runnable 接口 不会返回结果或抛出检查异常,但是 Callable 接口可以。
所以,如果任务不需要返回结果或抛出异常推荐使用 Runnable 接口 ,这样代码看起来会更加简洁。
所以,如果任务不需要返回结果或抛出异常推荐使用 Runnable 接口 ,这样代码看起来会更加简洁。
工具类 Executors 可以实现将 Runnable 对象转换成 Callable 对象。
Executors.callable(Runnable task) 或 Executors.callable(Runnable task, Object result)
Executors.callable(Runnable task) 或 Executors.callable(Runnable task, Object result)
执行 execute() 方法和 submit() 方法的区别
execute() 方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否;
submit() 方法用于提交需要返回值的任务。线程池会返回一个 Future 类型的对象,通过这个 Future 对象可以判断任务是否执行成功,
并且可以通过 Future 的 get() 方法来获取返回值,get() 方法会阻塞当前线程直到任务完成,而使用 get(long timeout,TimeUnit unit)
方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完。
并且可以通过 Future 的 get() 方法来获取返回值,get() 方法会阻塞当前线程直到任务完成,而使用 get(long timeout,TimeUnit unit)
方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完。
如何创建线程池
《阿里巴巴 Java 开发手册》中强制线程池不允许使用 Executors 去创建,而是 通过 ThreadPoolExecutor(JUC包下)的方式,
这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。
这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。
方式一:通过 ThreadPoolExecutor 构造方法(推荐)
方式二:通过 Executor 框架的工具类 Executors(不推荐)
FixedThreadPool : 方法返回一个 固定线程数量 的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池
中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。
中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。
SingleThreadExecutor: 方法返回一个 只有一个线程 的线程池。若多余一个任务被提交到该线程池,
任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。
任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。
CachedThreadPool: 该方法返回一个 可根据实际情况调整线程数量 的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。
对应 Executors 工具类中的方法如图所示:
线程池的 5 种状态
线程池的状态和线程的状态不同,线程池有自己的状态。
线程池分别有如下 5 种状态:
- RUNNING:对现有任务 正常 处理,且能接受新任务;(线程池一被创建,就是Running状态)
- SHUTDOWN:不在接受新任务,但是还是会处理队列中的剩余任务;(调用shutdown() 方法)
- STOP:不再接受新任务,不处理已添加进来的任务,而且还会中断正在处理的任务;(调用 shutdownNow() 方法)
- TIDYING:所有任务执行完毕后,当前线程池中的活跃线程数为 0。到此状态后,会调用线程池的 terminated() 方法。
- TERMINATED:销毁状态,执行完线程的 terminated() 方法后就会变为此状态。
线程池状态之间的转移
线程池的状态转移有两条路径:
- 当调用 shutdown() 方法时,线程池的状态会从 RUNNING 到 SHUTDOWN,再到 TIDYING,最后到 TERMENATED 销毁状态。
- 当调用 shutdownNow() 方法时,线程池的状态会从 RUNNING 到 STOP,再到 TIDYING,最后到 TERMENATED 销毁状态。
ThreadPoolExecutor 类分析
ThreadPoolExecutor 类中提供的四个构造方法。直接来看最长的那个,其余三个都是在这个构造方法的基础上产生,
其他几个构造方法都是给定某些默认参数的构造方法,比如默认制定拒绝策略是什么 ......
其他几个构造方法都是给定某些默认参数的构造方法,比如默认制定拒绝策略是什么 ......
ThreadPoolExecutor 构造函数 7 大参数
ThreadPoolExecutor 3 个最重要的参数:
- corePoolSize : 核心线程数
- maximumPoolSize : 最大线程数
- workQueue: 任务队列(阻塞)
ThreadPoolExecutor 其他常见参数:
keepAliveTime:临时线程的最大存活时间
unit:keepAliveTime 参数的时间单位,存活时间的单位
threadFactory:用哪个线程工厂创建线程
handler:饱和时的拒绝策略;线程忙,任务满的时候,新任务来了怎么办
keepAliveTime:临时线程的最大存活时间
unit:keepAliveTime 参数的时间单位,存活时间的单位
threadFactory:用哪个线程工厂创建线程
handler:饱和时的拒绝策略;线程忙,任务满的时候,新任务来了怎么办
有关 7 大参数的常见问题
核心线程数是执行中主要的线程
最大线程数是核心线程和临时线程的总和
临时线程的创建时机:当核心线程都在工作,而且任务队列已经排满时,就会启动临时线程来工作;
当任务队列未满时,不会创建临时线程,而是会等核心线程处理完任务后来接待新的任务。
当任务队列未满时,不会创建临时线程,而是会等核心线程处理完任务后来接待新的任务。
拒绝策略执行时机:当核心线程和临时线程都在忙,还有新任务来时,就会执行拒绝策略
图示说明
饱和时的 4 大拒绝策略
详解在下面
CAS
简介
CAS 全称是 compare and swap(比较并交换),是一种 无锁算法。在 不使用锁(没有线程被阻塞)
的情况下实现 多线程之间的变量同步。CAS 操作包含三个操作数 — 内存位置、预期数值和新值。
的情况下实现 多线程之间的变量同步。CAS 操作包含三个操作数 — 内存位置、预期数值和新值。
CAS 的实现逻辑是将内存位置处的数值与预期数值想比较,若相等,则将内存位置处的值替换为新值。若不相等,则不做任何操作。
在 Java 中,Java 并没有直接实现 CAS,CAS 相关的实现是通过 C++ 内联汇编的形式实现的。Java 代码需通过 JNI 才能调用。
CAS 是一条 CPU 的原子指令(cmpxchg指令),在执行此命令时,CPU 禁止一切中断请求,不会造成所谓的
数据不一致问题,Unsafe 提供的 CAS 方法(如compareAndSwapXXX)底层实现即为 CPU 指令 cmpxchg
CAS 是一条 CPU 的原子指令(cmpxchg指令),在执行此命令时,CPU 禁止一切中断请求,不会造成所谓的
数据不一致问题,Unsafe 提供的 CAS 方法(如compareAndSwapXXX)底层实现即为 CPU 指令 cmpxchg
示例
为什么要用 CAS
使用 Synchronized 也可以实现数据的原子性操作,确保了线程安全。为什么还要 CAS ?
Synchronized 虽然确保了线程安全,但是性能却不是最优的。因为它会上锁,会阻塞其他线程。
源码分析
getAndIncrement() 源码:
点击 unsafe 对象,在 AtomicInteger 数据定义的部分,我们还获取了 unsafe 实例,并且定义了 valueOffset。再看到 static 块,懂类加载过程的都知道,
static 块的加载发生于类加载的时候,是最先初始化的,这时候我们调用 unsafe 的 objectFieldOffset() 方法(本地方法)从 Atomic 类文件中获取 value
的偏移量,那么 valueOffset 其实就是记录 value 值在内存中的偏移地址,即原来 value 的内存地址。
有了 value 在工作内存中的偏移地址之后,我们就可以用 unsafe 直接操作这个地址了,通过这个地址我们可以获取原值,也可以写入新值。
因为是修改工作内存中的 value 值,所以 value 是用 volatile 修饰的,保证了多线程之间的可见性和避免指令重排。
static 块的加载发生于类加载的时候,是最先初始化的,这时候我们调用 unsafe 的 objectFieldOffset() 方法(本地方法)从 Atomic 类文件中获取 value
的偏移量,那么 valueOffset 其实就是记录 value 值在内存中的偏移地址,即原来 value 的内存地址。
有了 value 在工作内存中的偏移地址之后,我们就可以用 unsafe 直接操作这个地址了,通过这个地址我们可以获取原值,也可以写入新值。
因为是修改工作内存中的 value 值,所以 value 是用 volatile 修饰的,保证了多线程之间的可见性和避免指令重排。
点击 getAndAddInt ,通过分析发现,如果 object(AtomicInteger) 内的 value 和 expect 相等,就证明没有其他线程
改变过这个变量,那么就更新它为 update,如果这一步的 CAS 没有成功,那就采用自旋的方式继续进行CAS操作。
这个操作是一个 do-while 循环,通过 CAS 一直进行比较,直到成功才退出循环,这就是一个 自旋锁。
改变过这个变量,那么就更新它为 update,如果这一步的 CAS 没有成功,那就采用自旋的方式继续进行CAS操作。
这个操作是一个 do-while 循环,通过 CAS 一直进行比较,直到成功才退出循环,这就是一个 自旋锁。
CAS 的缺点
循环会耗时,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给CPU带来很大的压力。
一次性只能保证一个共享变量的原子性,而不能保证整个代码块的原子性。
它会存在 ABA 问题
什么是 ABA 问题
问题引出
线程1:期望值是1,要变成2;
线程2:先把1变成3,又把3变回1;
如果线程1先拿到A(此时为1),把A变成2之前线程2进来了,一顿操作,把A值先改为3,又改回1(相当于还原了)。 之后线程1终于要来操作A了,
要把A的值改为2,此时对于线程1来说,A的值还是1,所以CAS成功,但此1已经不是原来的1了,所以就出现了问题。
如果是基本类型不会产生影响,但是如果是引用类型,Java是值传递,就会有影响了。
传递的值为变量的地址,可能地址没变但是地址本身存储的值已经改变了,所以骗过了线程1;
线程2:先把1变成3,又把3变回1;
如果线程1先拿到A(此时为1),把A变成2之前线程2进来了,一顿操作,把A值先改为3,又改回1(相当于还原了)。 之后线程1终于要来操作A了,
要把A的值改为2,此时对于线程1来说,A的值还是1,所以CAS成功,但此1已经不是原来的1了,所以就出现了问题。
如果是基本类型不会产生影响,但是如果是引用类型,Java是值传递,就会有影响了。
传递的值为变量的地址,可能地址没变但是地址本身存储的值已经改变了,所以骗过了线程1;
示例
解决 ABA 问题需要使用 Atomic 原子类中的原子引用(引用类型原子类),下面会讲到。
Atomic 原子类
简介
Atomic 是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。
所谓原子类说简单点就是具有原子/原子操作特征的类。
并发包 java.util.concurrent 的原子类都存放在java.util.concurrent.atomic下,如下图所示:
JUC 包中的 4 类原子类
基本类型:
- AtomicInteger:整型原子类
- AtomicLong:长整型原子类
- AtomicBoolean:布尔型原子类
数组类型:
- AtomicIntegerArray:整型数组原子类
- AtomicLongArray:长整型数组原子类
- AtomicReferenceArray:引用类型数组原子类
引用类型:
- AtomicReference:引用类型原子类
- AtomicStampedReference:原子更新带有版本号(int) 的引用类型。该类将整数值与引用关联起来,
可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。 - AtomicMarkableReference :原子更新带有标记位(boolean) 的引用类型。相当于 AtomicStampedReference 的简化版。它只关心
变量是否被更改过,而不关心被更改了几次。它不能解决 ABA 问题,因为布尔类型只有两种状态,修改了再改回来也不知道。
对象的属性修改类型:
- AtomicIntegerFieldUpdater:原子更新整型字段的更新器
- AtomicLongFieldUpdater:原子更新长整型字段的更新器
- AtomicReferenceFieldUpdater:原子更新引用类型字段的更新器
AtomicInteger 的使用
AtomicInteger 类常用方法
使用 AtomicInteger 之后,不用对 increment() 方法加锁也可以保证线程安全。
AtomicInteger 类线程安全的原理
AtomicInteger 类主要利用 CAS (compare and swap) + volatile 和 native 方法 来保证原子操作,
从而 避免 synchronized 的高开销,执行效率大为提升。
从而 避免 synchronized 的高开销,执行效率大为提升。
CAS 的原理是拿期望的值和原本的一个值作比较,如果相同则更新成新的值。UnSafe 类的 objectFieldOffset() 方法是一个本地方法,
这个方法是用来拿到“原来的值”的内存地址,返回值是 valueOffset。另外 value 是一个 volatile 变量,在内存中可见,因此 JVM
可以保证任何时刻任何线程总能拿到该变量的最新值。
这个方法是用来拿到“原来的值”的内存地址,返回值是 valueOffset。另外 value 是一个 volatile 变量,在内存中可见,因此 JVM
可以保证任何时刻任何线程总能拿到该变量的最新值。
原子引用 AtomicStampedReference 解决 ABA 问题
ABA 问题的根本在于 CAS 在修改变量的时候,无法记录变量的状态,比如修改的次数,否修改过这个变量。
原子引用概念
原子引用其实和原子包装类是差不多的概念,就是将一个java类,用原子引用类进行包装起来,那么这个类就具备了原子性
新增一种机制,也就是 修改版本号,类似于时间戳的概念。
如果T1修改的时候,版本号为2,落后于现在的版本号3,所以要重新获取最新值,
这里就提出了一个使用 时间戳版本号,来解决 ABA 问题的思路。
AtomicStampedReference(时间戳原子引用),这里应用于版本号的更新,也就是每次更新的时候,
需要比较期望值和当前值,以及期望版本号和当前版本号。
需要比较期望值和当前值,以及期望版本号和当前版本号。
小心这个陷阱
看 compareAndSet 的源码,里面是使用 == 进行比较的
由于 new AtomicStampedReference<> 的时候声明泛型 ,肯定是使用的装箱类,这个时候传入值类型将会自动装箱
自动装箱的后果就是地址不一致,使用 == 判断的结果就为 false,则更新值都失败了
总结:泛型最好不为包装类型,使用包装类型得保证比较时候传入的为同一个装箱类;可以为引用类型,重写 equals 方法。
如何才能保证传入的值是同一个装箱类?
- 拿 Integer 来说,在 Integer 中,使用了对象缓存机制,默认范围是 -128~127,如果超出了这个范围,
就会创建新的对象,这个对象和之前的对象地址就不一样。所以需要控制数值范围。 - 在区间内的 Integer 类型的值可以直接使用 == 进行判断,但是在区间外的值需要使用 equals() 方法进行判断。
例如,原子引用的泛型为 String,由于 String 重写了 equals() 方法,比较的还是值,所以不会出现上面的坑。
示例
注意,给 atomicStampedReference 赋的值要在 Integer 区间内,否则 compareAndSet 会一直更新值失败,
掉进上面的坑,因为 compareAndSet 源码是使用 == 号进行比较的。
掉进上面的坑,因为 compareAndSet 源码是使用 == 号进行比较的。
AQS
简介
AQS 的全称为 AbstractQueuedSynchronizer ,翻译过来的意思就是抽象队列同步器。在 JUC 的 locks 包下面。
AQS 就是一个抽象类,主要用来构建锁和同步器。
AQS 是一个用来构建锁和同步器的框架,使用 AQS 能简单且高效地构造出大量应用广泛的同步器,比如我们提到的
ReentrantLock,Semaphore,其他的诸如 ReentrantReadWriteLock,SynchronousQueue,FutureTask 等
等皆是基于 AQS 的。当然,我们自己也能利用 AQS 非常轻松容易地构造出符合我们自己需求的同步器。
ReentrantLock,Semaphore,其他的诸如 ReentrantReadWriteLock,SynchronousQueue,FutureTask 等
等皆是基于 AQS 的。当然,我们自己也能利用 AQS 非常轻松容易地构造出符合我们自己需求的同步器。
使用 AQS 能简单高效地构造出大量的同步器,如:
- ReentrantLock
- Semaphore
- CountDownLatch
- FutureTask
- ReentrantReadWriteLock
AQS 框架的方法
AQS 原理分析
AQS 原理概览
AQS 核心思想:
- 如果被请求的共享资源 空闲,则将当前请求资源的线程设置为 有效的工作线程,并且将 共享资源设置为锁定状态。
- 如果被请求的共享资源 被占用,那么就 需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用
CLH 队列锁 实现的 — 将暂时获取不到锁的线程加入到队列中。
AQS(AbstractQueuedSynchronizer)原理图:
AQS 使用一个被 volatile 修饰的 int 成员变量 state 来表示同步状态,通过内置的 FIFO 队列来完成获取资源线程的排队工作。
AQS 使用 CAS 对该同步状态进行原子操作实现对其值的修改。
AQS 使用 CAS 对该同步状态进行原子操作实现对其值的修改。
状态信息通过 protected 类型的 getState,setState,compareAndSetState 进行操作
AQS 对资源的共享方式
有两种方式
1. Exclusive(独占):只有一个线程能执行,如 ReentrantLock。又可分为公平锁和非公平锁:
- 公平锁:按照线程在队列中的排队顺序,先到者先拿到锁
- 非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的
ReentrantLock 中相关的源码解读
ReentrantLock 默认采用非公平锁,因为考虑获得更好的性能,通过 boolean 来决定是否用公平锁(传入 true 用公平锁)。
ReentrantLock 中公平锁的 lock 方法
非公平锁的 lock 方法
总结,公平锁和非公平锁只有两处不同:
- 非公平锁在调用 lock 后,首先就会调用 CAS 进行一次抢锁,如果这个时候恰巧锁没有被占用,那么直接就获取到锁返回了。
- 非公平锁在 CAS 失败后,和公平锁一样都会进入到 tryAcquire 方法,在 tryAcquire 方法中,如果发现锁这个时候被释放了(state == 0),
非公平锁会直接 CAS 抢锁,但是 公平锁会判断等待队列是否有线程处于等待状态,如果有则不去抢锁,乖乖排到后面。
公平锁和非公平锁就这两点区别,如果这两次 CAS 都不成功,那么后面非公平锁和公平锁是一样的,都要进入到阻塞队列等待唤醒。
相对来说,非公平锁会有更好的性能,因为它的吞吐量比较大。当然,非公平锁让获取锁的时间变得更加不确定,
可能会导致在阻塞队列中的线程长期处于饥饿状态。
相对来说,非公平锁会有更好的性能,因为它的吞吐量比较大。当然,非公平锁让获取锁的时间变得更加不确定,
可能会导致在阻塞队列中的线程长期处于饥饿状态。
2. Share(共享):多个线程可同时执行,如 CountDownLatch、Semaphore、 CyclicBarrier、ReadWriteLock,都会在后面讲到。
ReentrantReadWriteLock 可以看成是组合式,因为 ReentrantReadWriteLock 也就是读写锁允许 多个线程同时对某一资源进行读。
不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源 state 的获取与释放方式即可,
至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS 已经在顶层实现好了。
至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS 已经在顶层实现好了。
AQS 底层使用了模板方法模式
同步器的设计是基于模板方法模式的,如果需要自定义同步器一般的方式是这样(模板方法模式很经典的一个应用):
- 使用者 继承 AbstractQueuedSynchronizer 并重写指定的方法。(这些重写方法很简单,无非是对于共享资源 state 的获取和释放)
- 将 AQS 组合在自定义同步组件的实现中,并调用其模板方法,而 这些模板方法会调用使用者重写的方法。
这和我们以往通过实现接口的方式有很大区别,这是模板方法模式很经典的一个运用。
AQS 使用了模板方法模式,自定义同步器时需要重写下面几个 AQS 提供的钩子方法:
钩子方法是一种被声明在抽象类中的方法,它可以是空方法(由子类实现),也可以是默认实现的方法。
模板设计模式通过钩子方法控制固定步骤的实现。
除了上面提到的钩子方法之外,AQS 类中的其他方法都是 final ,所以无法被其他类重写。
模板设计模式通过钩子方法控制固定步骤的实现。
除了上面提到的钩子方法之外,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。
中的一种即可。但 AQS 也支持自定义同步器同时实现独占和共享两种方式,如 ReentrantReadWriteLock。
使用 AQS
在要构建的同步类中加一个私有静态内部类:
private class Sync extends AbstractQueuedSynchronizer
在子类中覆盖 AQS 的 try 前缀等方法,这样 Sync 将在执行获取和释放方法时,调用这些被子类覆盖了的
try 方法来判断某个操作是否能执行(模板方法模式,就是基于继承该类,然后根据需要重写模板方法)
try 方法来判断某个操作是否能执行(模板方法模式,就是基于继承该类,然后根据需要重写模板方法)
一个 AQS 实现简单闭锁的示例:
AQS 组件
CountDownLatch(减法计数器)
CountDownLatch 允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕。
原理:
CountDownLatch 是 共享锁 的一种实现,它 默认构造 AQS 的 state 值为 count。当线程使用 countDown() 方法时,其实 使用了
tryReleaseShared 方法以 CAS 的操作来减少 state,直至 state 为 0 。当 调用 await() 方法的时候,如果 state 不为 0,那就证
明任务还没有执行完毕,await() 方法就会一直阻塞,也就是说 await() 方法之后的语句不会被执行。然后,CountDownLatch
会自旋 CAS 判断 state == 0,如果 state == 0 的话,就会 释放所有等待的线程,await() 方法之后的语句得到执行。
CountDownLatch 是 共享锁 的一种实现,它 默认构造 AQS 的 state 值为 count。当线程使用 countDown() 方法时,其实 使用了
tryReleaseShared 方法以 CAS 的操作来减少 state,直至 state 为 0 。当 调用 await() 方法的时候,如果 state 不为 0,那就证
明任务还没有执行完毕,await() 方法就会一直阻塞,也就是说 await() 方法之后的语句不会被执行。然后,CountDownLatch
会自旋 CAS 判断 state == 0,如果 state == 0 的话,就会 释放所有等待的线程,await() 方法之后的语句得到执行。
CountDownLatch 类可以设置一个 减法计数器,然后 通过 countDown() 方法来进行 -1 的操作,
使用 await() 方法等待计数器归零,然后再继续执行 await() 方法之后的语句。
使用 await() 方法等待计数器归零,然后再继续执行 await() 方法之后的语句。
两种典型用法
1. 某一线程在开始运行前等待 n 个线程执行完毕:
将 CountDownLatch 的计数器初始化为 n (new CountDownLatch(n)),每当一个任务线程执行完毕,
就将计数器减 1 (countdownlatch.countDown()),当计数器的值变为 0 时,在 CountDownLatch 上
await() 的线程就会被唤醒。一个典型应用场景就是 启动一个服务时,主线程需要等待多个组件加载完毕,之后再继续执行。
将 CountDownLatch 的计数器初始化为 n (new CountDownLatch(n)),每当一个任务线程执行完毕,
就将计数器减 1 (countdownlatch.countDown()),当计数器的值变为 0 时,在 CountDownLatch 上
await() 的线程就会被唤醒。一个典型应用场景就是 启动一个服务时,主线程需要等待多个组件加载完毕,之后再继续执行。
2. 实现多个线程开始执行任务的最大并行性:
注意是并行性,不是并发,强调的是多个线程在某一时刻同时开始执行。类似于赛跑,将多个线程放到起点,等待发令枪响,
然后同时开跑。做法是初始化一个共享的 CountDownLatch 对象,将其计数器初始化为 1 (new CountDownLatch(1)),
多个线程在开始执行任务前首先 coundownlatch.await(),当主线程调用 countDown() 时,计数器变为 0,多个线程同时被唤醒。
注意是并行性,不是并发,强调的是多个线程在某一时刻同时开始执行。类似于赛跑,将多个线程放到起点,等待发令枪响,
然后同时开跑。做法是初始化一个共享的 CountDownLatch 对象,将其计数器初始化为 1 (new CountDownLatch(1)),
多个线程在开始执行任务前首先 coundownlatch.await(),当主线程调用 countDown() 时,计数器变为 0,多个线程同时被唤醒。
CountDownLatch 的不足
CountDownLatch 是 一次性 的,计数器的值只能在构造方法中初始化一次,之后没有
任何机制再次对其设置值,当 CountDownLatch 使用完毕后,它不能再次被使用。
任何机制再次对其设置值,当 CountDownLatch 使用完毕后,它不能再次被使用。
CyclickBarrier(加法计数器)
CyclicBarrier 看英文单词就可以看出大概就是循环阻塞的意思。所以还常称为循环栅栏。
CyclicBarrier 和 CountDownLatch 非常类似,它也可以实现线程间的技术等待,
但是它的 功能比 CountDownLatch 更加复杂和强大。主要应用场景和 CountDownLatch 类似。
但是它的 功能比 CountDownLatch 更加复杂和强大。主要应用场景和 CountDownLatch 类似。
CountDownLatch 的实现是基于 AQS 的,而 CycliBarrier 是基于 ReentrantLock(ReentrantLock 也属于 AQS 同步器)和 Condition 的。
主要方法如下,仔细看注释:
public CyclicBarrier(int parties) 的构造方法第一个参数是目标障碍数,每次执行 CyclicBarrier 一次障碍数会加一,
如果达到了目标障碍数,才会执行 cyclicBarrier.await() 之后的语句。所以可以将 CyclicBarrier 理解为加 1 操作。
如果达到了目标障碍数,才会执行 cyclicBarrier.await() 之后的语句。所以可以将 CyclicBarrier 理解为加 1 操作。
示例:
注意:
- cyclicBarrier.await() 之后的代码是 等到目标障碍数达到后再统一全部执行。
- CyclicBarrier(int parties, Runnable barrierAction) 中的第二个参数是 Runnable 类型的线程方法,
等达到了目标障碍数,就会执行我们传入的 Runnable。 - 先执行 Runnable 方法,再执行 cyclicBarrier.await() 之后的代码。
CyclicBarrier 源码分析
当调用 CyclicBarrier 对象调用 await() 方法时,实际上调用的是 dowait(false, 0L) 方法。 await() 方法就像树立起
一个栅栏的行为一样,将线程挡住了,当拦住的线程数量达到 parties 的值时,栅栏才会打开,线程才得以通过执行。
一个栅栏的行为一样,将线程挡住了,当拦住的线程数量达到 parties 的值时,栅栏才会打开,线程才得以通过执行。
dowait(false, 0L):
总结:
CyclicBarrier 内部通过一个 count 变量作为计数器,count 的初始值为 parties 属性的初始化值,每当一个线程到了栅栏这里了,
那么就将计数器减一。如果 count 值为 0 了,表示这是这一代最后一个线程到达栅栏,就尝试执行我们构造方法中输入的任务。
CyclicBarrier 内部通过一个 count 变量作为计数器,count 的初始值为 parties 属性的初始化值,每当一个线程到了栅栏这里了,
那么就将计数器减一。如果 count 值为 0 了,表示这是这一代最后一个线程到达栅栏,就尝试执行我们构造方法中输入的任务。
CyclicBarrier 和 CountDownLatch 的区别:
- CyclicBarrier 可以重置,重新使用,但是 CountDownLatch 的值等于0时,就不可重复用了。
- 对于 CountDownLatch,重点是一个或者多个线程,等待其他多个线程完成某件事情后这一个或者多个线程才能执行。
而对于 CyclicBarrier,重点是多个线程互相等待,直到达到一个同步点,再继续一起执行。
Semaphore(信号灯)
synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,
Semaphore (信号量) 可以指定 多个线程同时访问某个资源。
Semaphore (信号量) 可以指定 多个线程同时访问某个资源。
通常用于限制可以访问某些(物理或逻辑)资源数量的线程数。
- Semaphore 的构造方法中传入的第一个参数是最大信号量(可以看成最大线程池),每个信号量初始化为一个最多只能分发一个许可证。
- 使用 acquire() 方法获得许可证,release() 方法释放许可。
- 除了 acquire() 方法之外,另一个比较常用的与之对应的方法是 tryAcquire() 方法,该方法如果获取不到许可就立即返回 false。
当然一次也可以一次拿取和释放多个许可,不过一般没有必要这样做:
acquire(int count) / release(int count)
acquire(int count) / release(int count)
Semaphore 有两种模式:
Semaphore 对应的两个构造方法如下:
- 公平模式: 调用 acquire() 方法的顺序就是获取许可证的顺序,遵循 FIFO;
- 非公平模式(默认): 抢占式的。
Semaphore 对应的两个构造方法如下:
原理分析:
Semaphore 与 CountDownLatch 一样,也是共享锁的一种实现。它 默认构造 AQS 的 state 为 permits。当执行任务的线程数量超出 permits,
那么多余的线程将会被放入阻塞队列 Park, 并自旋判断 state 是否大于 0。只有当 state 大于 0 的时候,阻塞的线程才能继续执行。如果先前执行任
务的线程 执行了 release() 方法,release() 方法使得 state 的变量会加 1,那么 自旋的线程便会判断成功。 如此,每次只有最多不超过 permits
数量的线程能自旋成功,便限制了执行任务线程的数量。
Semaphore 与 CountDownLatch 一样,也是共享锁的一种实现。它 默认构造 AQS 的 state 为 permits。当执行任务的线程数量超出 permits,
那么多余的线程将会被放入阻塞队列 Park, 并自旋判断 state 是否大于 0。只有当 state 大于 0 的时候,阻塞的线程才能继续执行。如果先前执行任
务的线程 执行了 release() 方法,release() 方法使得 state 的变量会加 1,那么 自旋的线程便会判断成功。 如此,每次只有最多不超过 permits
数量的线程能自旋成功,便限制了执行任务线程的数量。
示例:
原理:
- semaphore.acquire() 获得资源,如果资源已经使用完了,就等待资源释放后再进行使用
- semaphore.release() 释放资源,会将当前的信号量 +1,然后唤醒其他等待的线程
- 多个共享资源互斥的使用! 并发限流,控制最大的线程数
Java 中的各种锁
重要知识点汇总
线程池详解
Executor 框架
简介
Executor 框架是 Java5 之后引进的,在 Java 5 之后,通过 Executor 来启动线程比使用 Thread 的 start 方法更好,
除了更易管理,效率更好(用线程池实现,节约开销)外,还有关键的一点:有助于避免 this 逃逸问题。
this 逃逸是指 在构造函数返回之前其他线程就持有该对象的引用,调用尚未构造完全的对象的方法可能引发令人疑惑的错误。
除了更易管理,效率更好(用线程池实现,节约开销)外,还有关键的一点:有助于避免 this 逃逸问题。
this 逃逸是指 在构造函数返回之前其他线程就持有该对象的引用,调用尚未构造完全的对象的方法可能引发令人疑惑的错误。
Executor 框架不仅包括了线程池的管理,还提供了线程工厂、队列以及拒绝策略等,Executor 框架让并发编程变得更加简单。
Executor 框架结构(主要由三大部分组成)
1. 任务(Runnable/Callable)
执行任务需要实现的 Runnable 接口 或 Callable接口。Runnable 接口或 Callable 接口
实现类都可以被 ThreadPoolExecutor 或 ScheduledThreadPoolExecutor 执行。
实现类都可以被 ThreadPoolExecutor 或 ScheduledThreadPoolExecutor 执行。
2. 任务的执行(Executor)
如下图所示,包括任务执行机制的核心接口 Executor ,以及继承自 Executor 接口的 ExecutorService 接口。
ThreadPoolExecutor 和 ScheduledThreadPoolExecutor 这两个关键类实现了 ExecutorService 接口。
ThreadPoolExecutor 和 ScheduledThreadPoolExecutor 这两个关键类实现了 ExecutorService 接口。
这里提了很多底层的类关系,但是,实际上我们需要更多关注的是 ThreadPoolExecutor 这个类,
这个类在我们实际使用线程池的过程中,使用频率还是非常高的。
这个类在我们实际使用线程池的过程中,使用频率还是非常高的。
3. 异步计算的结果(Future)
Future 接口以及 Future 接口的实现类 FutureTask 类都可以代表异步计算的结果。
当我们把 Runnable接口 或 Callable 接口 的实现类提交给 ThreadPoolExecutor 或 ScheduledThreadPoolExecutor 执行。
调用 submit() 方法时会返回一个 FutureTask 对象。
调用 submit() 方法时会返回一个 FutureTask 对象。
Executor 框架的使用示意图
1. 主线程首先要创建实现 Runnable 或者 Callable 接口的任务对象
2. 把创建完成的实现 Runnable/Callable 接口的对象直接交给 ExecutorService 的 execute 方法执行:
ExecutorService.execute (Runnable command),或者也可以把 Runnable 对象或 Callable 对象
提交给 ExecutorService 的 submit 方法执行(ExecutorService.submit(Runnable task)或
ExecutorService.submit(Callable <T> task))。
ExecutorService.execute (Runnable command),或者也可以把 Runnable 对象或 Callable 对象
提交给 ExecutorService 的 submit 方法执行(ExecutorService.submit(Runnable task)或
ExecutorService.submit(Callable <T> task))。
3. 如果执行 ExecutorService.submit(…),ExecutorService 将返回一个实现Future接口的对象。
由于 FutureTask 实现了 Runnable,我们也可以创建 FutureTask,然后直接交给 ExecutorService 执行。
由于 FutureTask 实现了 Runnable,我们也可以创建 FutureTask,然后直接交给 ExecutorService 执行。
4. 最后,主线程可以执行 FutureTask.get() 方法来等待任务执行完成。主线程也可以执行
FutureTask.cancel(boolean mayInterruptIfRunning)来取消此任务的执行。
FutureTask.cancel(boolean mayInterruptIfRunning)来取消此任务的执行。
ThreadPoolExecutor 类分析(重要)
构造方法
ThreadPoolExecutor 类中提供的四个构造方法。直接来看最长的那个,其余三个都是在这个构造方法的基础上产生,
其他几个构造方法都是给定某些默认参数的构造方法,比如默认制定拒绝策略是什么 ......
其他几个构造方法都是给定某些默认参数的构造方法,比如默认制定拒绝策略是什么 ......
ThreadPoolExecutor 7 大参数
ThreadPoolExecutor 3 个最重要的参数:
- corePoolSize : 核心线程数
- maximumPoolSize : 最大线程数
- workQueue: 任务队列(阻塞)
ThreadPoolExecutor 其他常见参数:
keepAliveTime:临时线程的最大存活时间
unit:keepAliveTime 参数的时间单位,存活时间的单位
threadFactory:用哪个线程工厂创建线程
handler:饱和时的拒绝策略;线程忙,任务满的时候,新任务来了怎么办
keepAliveTime:临时线程的最大存活时间
unit:keepAliveTime 参数的时间单位,存活时间的单位
threadFactory:用哪个线程工厂创建线程
handler:饱和时的拒绝策略;线程忙,任务满的时候,新任务来了怎么办
有关 7 大参数的常见问题
核心线程数是执行中主要的线程
最大线程数是核心线程和临时线程的总和
临时线程的创建时机:当核心线程都在工作,而且任务队列已经排满时,就会启动临时线程来工作;
当任务队列未满时,不会创建临时线程,而是会等核心线程处理完任务后来接待新的任务。
当任务队列未满时,不会创建临时线程,而是会等核心线程处理完任务后来接待新的任务。
拒绝策略执行时机:当核心线程和临时线程都在忙,还有新任务来时,就会执行拒绝策略
图示说明
饱和时的 4 大拒绝策略
几个常见的对比
Runnable vs Callable
Runnable自 Java 1.0 以来一直存在,但Callable仅在 Java 1.5 中引入,目的就是为了来处理Runnable不支持的用例。Runnable 接口不会返回结果
或抛出检查异常,但是 Callable 接口可以。所以,如果任务不需要返回结果或抛出异常推荐使用 Runnable 接口,这样代码看起来会更加简洁。
或抛出检查异常,但是 Callable 接口可以。所以,如果任务不需要返回结果或抛出异常推荐使用 Runnable 接口,这样代码看起来会更加简洁。
工具类 Executors 可以实现将 Runnable 对象转换成 Callable 对象。
Executors.callable(Runnable task) 或 Executors.callable(Runnable task, Object result)。
Executors.callable(Runnable task) 或 Executors.callable(Runnable task, Object result)。
execute() vs submit()
- execute()方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否;
- submit()方法用于提交需要返回值的任务。线程池会返回一个 Future 类型的对象,通过这个 Future 对象可以判断任务是否执行成功,
并且可以通过 Future 的 get()方法来获取返回值,get()方法会阻塞当前线程直到任务完成,而使用 get(long timeout,TimeUnit unit)
方法的话,如果在 timeout 时间内任务还没有执行完,就会抛出 java.util.concurrent.TimeoutException。
这里只是为了演示使用,推荐使用 ThreadPoolExecutor 构造方法来创建线程池。
示例1:使用 get() 方法获取返回值。
示例2:使用 get(long timeout,TimeUnit unit) 方法获取返回值。
shutdown() vs shutdownNow()
shutdown(): 关闭线程池,线程池的状态变为 SHUTDOWN。线程池不再接受新任务了,但是队列里的任务得执行完毕。
shutdownNow(): 关闭线程池,线程的状态变为 STOP。线程池会终止当前正在运行的任务,并停止处理排队的任务并返回正在等待执行的 List。
isTerminated() vs isShutdown()
isShutDown():当调用 shutdown() 方法后返回为 true。
isTerminated():当调用 shutdown() 方法后,并且所有提交的任务完成后返回为 true
几种常见的线程池
FixedThreadPool : 方法返回一个 固定线程数量 的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池
中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。
中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。
SingleThreadExecutor: 方法返回一个 只有一个线程 的线程池。若多余一个任务被提交到该线程池,
任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。
任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。
CachedThreadPool: 该方法返回一个 可根据实际情况调整线程数量 的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。
对应 Executors 工具类中的方法如图所示:
《阿里巴巴 Java 开发手册》中强制线程池不允许使用 Executors 去创建,而是 通过 ThreadPoolExecutor(JUC包下)的方式,
这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。
这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。
ScheduledThreadPoolExecutor 详解
ScheduledThreadPoolExecutor 主要用来在给定的延迟后运行任务,或者定期执行任务。
这个在实际项目中基本不会被用到,也不推荐使用,只需要简单了解一下它的思想即可。
这个在实际项目中基本不会被用到,也不推荐使用,只需要简单了解一下它的思想即可。
线程池大小确定
线程池数量的确定一直是困扰着程序员的一个难题,大部分程序员在设定线程池大小的时候就是随心而定。
- 如果设置的线程池数量太小,同一时间有大量任务/请求需要处理,可能会导致大量的请求/任务在任务队列中排队等待执行,甚至会出现任务
队列满了之后任务/请求无法处理的情况,或者大量任务堆积在任务队列导致 OOM。这样很明显是有问题的! CPU 根本没有得到充分利用。
- 如果设置线程数量太大,大量线程可能会同时在争取 CPU 资源,这样会导致大量的上下文切换,从而增加线程的执行时间,影响了整体执行效率。
一个简单并且适用面比较广的公式
CPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1,
比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。
一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。
一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
I/O 密集型任务(2N): 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,
这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。
这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。
如何判断是 CPU 密集任务还是 IO 密集任务?
CPU 密集型简单理解就是利用 CPU 计算能力的任务,比如你在内存中对大量数据进行排序。
涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间
相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。
相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。
线程池在实际项目中的使用场景
线程池一般用于执行多个不相关联的耗时任务,没有多线程的情况下,
任务顺序执行,使用了线程池的话可让多个不相关联的任务同时执行。
任务顺序执行,使用了线程池的话可让多个不相关联的任务同时执行。
假设我们要执行下面三个任务,可能做同一件事情,也可能是不一样的事情。
线程池最佳实战
1. 使用 ThreadPoolExecutor 的构造函数声明线程池
线程池必须手动通过 ThreadPoolExecutor 的构造函数来声明,避免使用Executors 类的
newFixedThreadPool 和 newCachedThreadPool ,因为可能会有 OOM 的风险。
newFixedThreadPool 和 newCachedThreadPool ,因为可能会有 OOM 的风险。
除了避免 OOM 的原因之外,不推荐使用 Executors提供的两种快捷的线程池的原因还有:
- 实际使用中需要根据自己机器的性能、业务场景来手动配置线程池的参数比如核心线程数、使用的任务队列、饱和策略等等。
- 我们应该显示地给我们的线程池命名,这样有助于我们定位问题。
2. 监测线程池运行状态
可以通过一些手段来检测线程池的运行状态,比如 SpringBoot 中的 Actuator 组件。
除此之外,我们还可以利用 ThreadPoolExecutor 的相关 API 做一个简陋的监控。从下图可以看出,
ThreadPoolExecutor提供了获取线程池当前的线程数和活跃线程数、已经执行完成的任务数、正在排队中的任务数等等。
ThreadPoolExecutor提供了获取线程池当前的线程数和活跃线程数、已经执行完成的任务数、正在排队中的任务数等等。
下面是一个简单的 Demo。printThreadPoolStatus()会每隔一秒打印出线程池的线程数、活跃线程数、完成的任务数、以及队列中的任务数。
3. 建议不同类别的业务用不同的线程池
项目中多个业务需要用到线程池,是为每个线程池都定义一个还是说定义一个公共的线程池呢?
- 一般建议是不同的业务使用不同的线程池,配置线程池的时候根据当前业务的情况对当前线程池
进行配置,因为不同的业务的并发以及对资源的使用情况都不同,重心优化系统性能瓶颈相关的业务。
真实的事故案例
上面的代码可能会存在死锁的情况,为什么呢?
试想这样一种极端情况:假如我们线程池的核心线程数为 n,父任务(扣费任务)数量为 n,父任务下面有两个子任务(扣费任务下的子任务),
其中一个已经执行完成,另外一个被放在了任务队列中。由于父任务把线程池核心线程资源用完,所以子任务因为无法获取到线程资源无法正常
执行,一直被阻塞在队列中。父任务等待子任务执行完成,而子任务等待父任务释放线程池资源,这也就造成了 "死锁"。
其中一个已经执行完成,另外一个被放在了任务队列中。由于父任务把线程池核心线程资源用完,所以子任务因为无法获取到线程资源无法正常
执行,一直被阻塞在队列中。父任务等待子任务执行完成,而子任务等待父任务释放线程池资源,这也就造成了 "死锁"。
解决方法也很简单,就是新增加一个用于执行子任务的线程池专门为其服务。
4. 给线程池命名
初始化线程池的时候需要显示命名(设置线程池名称前缀),有利于定位问题。
默认情况下创建的线程名字类似 pool-1-thread-n 这样的,没有业务含义,不利于我们定位问题。
默认情况下创建的线程名字类似 pool-1-thread-n 这样的,没有业务含义,不利于我们定位问题。
线程命名通常有两种方式
1. 利用 guava 的 ThreadFactoryBuilder
2. 自己实现 ThreadFactor
5. 正确配置线程池参数
线程池数量的确定一直是困扰着程序员的一个难题,大部分程序员在设定线程池大小的时候就是随心而定。
- 如果设置的线程池数量太小,同一时间有大量任务/请求需要处理,可能会导致大量的请求/任务在任务队列中排队等待执行,甚至会出现任务
队列满了之后任务/请求无法处理的情况,或者大量任务堆积在任务队列导致 OOM。这样很明显是有问题的! CPU 根本没有得到充分利用。
- 如果设置线程数量太大,大量线程可能会同时在争取 CPU 资源,这样会导致大量的上下文切换,从而增加线程的执行时间,影响了整体执行效率。
一个简单并且适用面比较广的公式
CPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1,
比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。
一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。
一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
I/O 密集型任务(2N): 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,
这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。
这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。
如何判断是 CPU 密集任务还是 IO 密集任务?
CPU 密集型简单理解就是利用 CPU 计算能力的任务,比如你在内存中对大量数据进行排序。
涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间
相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。
相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。
并发集合容器
ConcurrentHashMap
HashMap 不是线程安全的,在并发场景下如果要保证一种可行的方式是使用 Collections.synchronizedMap() 方法来
包装我们的 HashMap。但这是通过使用一个全局的锁来同步不同线程间的并发访问,因此会带来不可忽视的性能问题。
包装我们的 HashMap。但这是通过使用一个全局的锁来同步不同线程间的并发访问,因此会带来不可忽视的性能问题。
源码分析
CopyOnWriteArrayList
简介
在很多应用场景中,读操作可能会远远大于写操作。由于读操作根本不会修改原有的数据,因此对于每次读取
都进行加锁其实是一种资源浪费。我们应该允许多个线程同时访问 List 的内部数据,毕竟读取操作是安全的。
都进行加锁其实是一种资源浪费。我们应该允许多个线程同时访问 List 的内部数据,毕竟读取操作是安全的。
这和我们之前在多线程章节讲过 ReentrantReadWriteLock 读写锁的思想非常类似,读写锁是读读共享、写写互斥、读写互斥、写读互斥。
CopyOnWriteArrayList 类 比读写锁的思想又更进一步。为了将读取的性能发挥到极致,CopyOnWriteArrayList 读取是完全不用加锁的,
并且更厉害的是:写入也不会阻塞读取操作。只有写入和写入之间需要进行同步等待。这样一来,读操作的性能就会大幅度提升。
并且更厉害的是:写入也不会阻塞读取操作。只有写入和写入之间需要进行同步等待。这样一来,读操作的性能就会大幅度提升。
如何实现
CopyOnWriteArrayList 类的所有可变操作(add,set 等等)都是通过 创建底层数组的新副本来实现的。当 List 需要被修改的时候,我并不修改原有内容,而是对原有数据进行一次复制,将修改的内容写入副本。写完之后,再将修改完的副本替换原来的数据,这样就可以保证写操作不会影响读操作了。
从 CopyOnWriteArrayList 的名字就能看出 CopyOnWriteArrayList 是满足 CopyOnWrite 的。所谓 CopyOnWrite
也就是说:在计算机,如果你想要对一块内存进行修改时,我们不在原有内存块中进行写操作,而是将内存拷贝一份,
在新的内存中进行写操作,写完之后呢,就将指向原来内存指针指向新的内存,原来的内存就可以被回收掉了。
也就是说:在计算机,如果你想要对一块内存进行修改时,我们不在原有内存块中进行写操作,而是将内存拷贝一份,
在新的内存中进行写操作,写完之后呢,就将指向原来内存指针指向新的内存,原来的内存就可以被回收掉了。
读取和写入源码简单分析
读取操作的实现:
- 读取操作没有任何同步控制和锁操作,理由就是内部数组 array 不会发生修改,
只会被另外一个 array 替换,因此可以保证数据安全。
写入操作的实现:
- 写入操作 add()方法在添加集合的时候加了锁,保证了同步,
避免了多线程写的时候会 copy 出多个副本出来。
ConcurrentLinkedQueue
Java 提供的线程安全的 Queue 分为阻塞队列和非阻塞队列:
- 其中阻塞队列的典型例子是 BlockingQueue,非阻塞队列 的典型例子是 ConcurrentLinkedQueue。
- 阻塞队列可以通过加锁来实现,非阻塞队列可以通过 CAS 操作实现,在实际应用中根据实际需要选用。
ConcurrentLinkedQueue 这个队列使用链表作为其数据结构,应该算是 在高并发环境中性能最好的队列了。
它之所有能有很好的性能,是因为其内部复杂的实现。内部代码就不分析了,主要使用 CAS 非阻塞算法来实现线程安全。
它之所有能有很好的性能,是因为其内部复杂的实现。内部代码就不分析了,主要使用 CAS 非阻塞算法来实现线程安全。
ConcurrentLinkedQueue 适合在对性能要求相对较高,同时对队列的读写存在多个线程同时进行的场景,
即如果对队列加锁的成本较高则适合使用无锁的 ConcurrentLinkedQueue 来替代。
即如果对队列加锁的成本较高则适合使用无锁的 ConcurrentLinkedQueue 来替代。
BlockingQueue
简介
阻塞队列(BlockingQueue)被广泛使用在“生产者-消费者”问题中,其原因是 BlockingQueue 提供了可阻塞的插入和移除的方法。
当队列容器已满,生产者线程会被阻塞,直到队列未满;当队列容器为空时,消费者线程会被阻塞,直至队列非空时为止。
当队列容器已满,生产者线程会被阻塞,直到队列未满;当队列容器为空时,消费者线程会被阻塞,直至队列非空时为止。
BlockingQueue 是一个接口,继承自 Queue,所以其实现类也可以作为 Queue 的实现来使用,
而 Queue 又继承自 Collection 接口。下面是 BlockingQueue 的相关实现类:
而 Queue 又继承自 Collection 接口。下面是 BlockingQueue 的相关实现类:
BlockingQueue 的四组 API
示例:
4 个常见的 BlockingQueue 的实现类
ArrayBlockingQueue
ArrayBlockingQueue 是 BlockingQueue 接口的有界队列实现类,底层采用 数组 来实现。
ArrayBlockingQueue 一旦创建,容量不能改变。其并发控制采用可重入锁 ReentrantLock ,不管是插入操作还是读取操作,
都需要获取到锁才能进行操作。当队列容量满时,尝试将元素放入队列将导致操作阻塞;尝试从一个空队列中取一个元素也会同样阻塞。
都需要获取到锁才能进行操作。当队列容量满时,尝试将元素放入队列将导致操作阻塞;尝试从一个空队列中取一个元素也会同样阻塞。
ArrayBlockingQueue 默认情况下是非公平队列,如果保证公平性,通常会降低吞吐量。
如果需要获得公平性的 ArrayBlockingQueue,可采用如下代码:
如果需要获得公平性的 ArrayBlockingQueue,可采用如下代码:
LinkedBlockingQueue
LinkedBlockingQueue 底层基于 单向链表实现的阻塞队列,可以当做无界队列也可以当做有界队列来使用,满足 FIFO 的特性,
与 ArrayBlockingQueue 相比起来具有更高的吞吐量,为了防止 LinkedBlockingQueue 容量迅速增,损耗大量内存。
通常在创建 LinkedBlockingQueue 对象时,会指定其大小,如果未指定,容量等于 Integer.MAX_VALUE 。
与 ArrayBlockingQueue 相比起来具有更高的吞吐量,为了防止 LinkedBlockingQueue 容量迅速增,损耗大量内存。
通常在创建 LinkedBlockingQueue 对象时,会指定其大小,如果未指定,容量等于 Integer.MAX_VALUE 。
相关构造方法:
PriorityBlockingQueue
PriorityBlockingQueue 是一个 支持优先级的无界阻塞队列。默认情况下元素采用自然顺序进行排序,也可以通过
自定义类实现 compareTo() 方法来指定元素排序规则,或者初始化时通过构造器参数 Comparator 来指定排序规则。
自定义类实现 compareTo() 方法来指定元素排序规则,或者初始化时通过构造器参数 Comparator 来指定排序规则。
PriorityBlockingQueue 并发控制采用的是可重入锁 ReentrantLock,队列为无界队列,
只能指定初始的队列大小,后面插入元素的时候,如果空间不够的话会自动扩容。
只能指定初始的队列大小,后面插入元素的时候,如果空间不够的话会自动扩容。
简单地说,它就是 PriorityQueue 的线程安全版本。不可以插入 null 值,同时,插入队列的对象必须是可比较大小的(comparable),
否则报 ClassCastException 异常。它的插入操作 put 方法不会 block,因为它是无界队列(take 方法在队列为空的时候会阻塞)。
否则报 ClassCastException 异常。它的插入操作 put 方法不会 block,因为它是无界队列(take 方法在队列为空的时候会阻塞)。
SynchronousQueue
SynchronousQueue:不存储元素的阻塞队列,也即单个元素的队列。
SynchronousQueue 实际上它不是一个真正的队列,因为 SynchronousQueue 没有容量。
与其他 BlockingQueue(阻塞队列)不同,SynchronousQueue 是一个不存储元素的 BlockingQueue。
只是它维护一组线程,这些线程在等待着把元素加入或移出队列。
与其他 BlockingQueue(阻塞队列)不同,SynchronousQueue 是一个不存储元素的 BlockingQueue。
只是它维护一组线程,这些线程在等待着把元素加入或移出队列。
SynchronousQueue 特点:
- put() 进去一个元素,就必须先 take() 出来,否则不能再 put() 进去值
- SynchronousQueue 的 take() 是使用了 Lock锁保证线程安全 的
- 特别适用于数据传递和交换的场景。比如对于经典生产者-消费者线程模型:
生产者和消费者之间等待对方的到来,然后交换数据,最后离开。
示例:
ConcurrentSkipListMap
简单认识跳表
对于一个单链表,即使链表是有序的,如果我们想要在其中查找某个数据,也只能从头到尾遍历链表,这样效率自然就会很低,跳表就不一样了。
跳表是一种可以用来 快速查找 的数据结构,有点类似于平衡树。它们都可以对元素进行快速的查找。但一个重要的区别是:
可以拥有更好的性能。而就查询的性能而言,跳表的时间复杂度也是 O(logn) ,所以在并发数据结构中,JDK 使用跳表来实现一个 Map。
跳表是一种可以用来 快速查找 的数据结构,有点类似于平衡树。它们都可以对元素进行快速的查找。但一个重要的区别是:
- 对平衡树的插入和删除往往很可能导致平衡树进行一次全局的调整。
- 而 对跳表的插入和删除只需要对整个数据结构的局部进行操作即可。
可以拥有更好的性能。而就查询的性能而言,跳表的时间复杂度也是 O(logn) ,所以在并发数据结构中,JDK 使用跳表来实现一个 Map。
跳表的本质是同时维护了多个链表,并且链表是分层的:
最低层的链表维护了跳表内所有的元素,每上面一层链表都是下面一层的子集。
跳表内的所有链表的元素都是排序的。查找时,可以从顶级链表开始找。一旦发现被查找的元素大于当前链表中的取值,
就会转入下一层链表继续找。这也就是说在查找过程中,搜索是跳跃式的。
最低层的链表维护了跳表内所有的元素,每上面一层链表都是下面一层的子集。
跳表内的所有链表的元素都是排序的。查找时,可以从顶级链表开始找。一旦发现被查找的元素大于当前链表中的取值,
就会转入下一层链表继续找。这也就是说在查找过程中,搜索是跳跃式的。
如上图所示,在跳表中查找元素 18。原来需要遍历 18 次,现在只需要 7 次即可。
针对链表长度比较大的时候,构建索引查找效率的提升就会非常明显。
针对链表长度比较大的时候,构建索引查找效率的提升就会非常明显。
从上面很容易看出,跳表是一种利用空间换时间的算法。
使用跳表实现 Map 和使用哈希算法实现 Map 的另外一个不同之处是:哈希并不会保存元素的顺序,
而跳表内所有的元素都是排序的。因此在对跳表进行遍历时,你会得到一个有序的结果。所以,如果
需要有序性,那么跳表就是不二的选择。JDK 中实现这一数据结构的类是 ConcurrentSkipListMap。
而跳表内所有的元素都是排序的。因此在对跳表进行遍历时,你会得到一个有序的结果。所以,如果
需要有序性,那么跳表就是不二的选择。JDK 中实现这一数据结构的类是 ConcurrentSkipListMap。
ThreadLocal 关键字详解
ThreadLocal 对象可以提供线程局部变量,每个线程Thread拥有一份自己的 副本变量,多个线程互不干扰。
常见面试题
ThreadLocal的 key 是弱引用,那么在 ThreadLocal.get() 的时候,发生 GC 之后,key 是否为null?
ThreadLocal 中 ThreadLocalMap 的数据结构?
ThreadLocalMap 的 Hash 算法?
ThreadLocalMap 中 Hash 冲突如何解决?
ThreadLocalMap 的扩容机制?
ThreadLocalMap 中过期 key 的清理机制?探测式清理和启发式清理流程?
ThreadLocalMap.set() 方法实现原理?
ThreadLocalMap.get() 方法实现原理?
项目中ThreadLocal使用情况?遇到的坑?
ThreadLocal 演示
示例代码:
每个线程 Thread 拥有一份自己的副本变量
ThreadLocal 的内部设计
常见的误解
如果不去看源码的话,很可能会误以为 ThreadLocal 是这样设计的:
每个 ThreadLocal 都创建一个 Map,然后线程作为 key,要存储的局部变量作为 value,这样就能达到各个线程的局部变量隔离的效果。
这是最简单的设计方法,JDK 最早期的 ThreadLocal 确实是这样设计的,但现在早已不是了。此设计每创建一个线程都要增加一个 Entry。
每个 ThreadLocal 都创建一个 Map,然后线程作为 key,要存储的局部变量作为 value,这样就能达到各个线程的局部变量隔离的效果。
这是最简单的设计方法,JDK 最早期的 ThreadLocal 确实是这样设计的,但现在早已不是了。此设计每创建一个线程都要增加一个 Entry。
现在的设计
在 JDK 8 中,ThreadLocal 的设计是:
每个 Thread 维护一个 ThreadLocalMap,Thread 类有一个类型为 ThreadLocal.ThreadLocalMap 的实例变量 threadLocals,
也就是说每个线程有一个自己的 ThreadLocalMap。这个 Map 的 key 是 ThreadLocal 实例本身, value 是要存储的值(Object类型)
此设计每创建一个线程不一定会增加一个 Entry,当此线程不使用 ThreadLocal 时,Map 就为空。
每个 Thread 维护一个 ThreadLocalMap,Thread 类有一个类型为 ThreadLocal.ThreadLocalMap 的实例变量 threadLocals,
也就是说每个线程有一个自己的 ThreadLocalMap。这个 Map 的 key 是 ThreadLocal 实例本身, value 是要存储的值(Object类型)
此设计每创建一个线程不一定会增加一个 Entry,当此线程不使用 ThreadLocal 时,Map 就为空。
具体的过程:
- 每个 Thread 线程内部都有一个 Map(ThreadLocalMap)
- Map 里面存储 ThreadLocal 对象(key)和存储的变量(value)
- Thread 内部的 Map 是由 ThreadLocal 维护的,由 ThreadLocal 负责向 Map 获取和设置线程存储的变量
- 对于不同的线程,每次获取副本线程时,别的线程并不能获取到当前线程的副本线程,形成了副本的隔离
这个设计与一开始的设计刚好相反,这样设计有如下 两个优势:
- 这样设计之后 每个 Map 存储的 Entry 数量就会变少。因为 之前的存储数量由 Thread 的数量决定,现在是由 ThreadLocal 的数量决定。
在实际运用当中,往往 ThreadLocal 的数量要少于 Thread 的数量(有些线程不需要使用 ThreadLocal)。 - 当 Thread 销毁之后,对应的 ThreadLocalMap 也会随之销毁,能减少内存的使用。
- 当并发量较大时,因为所有的 entry 都存在一个 map 里,所有线程都去操作同一个 map,会导致访问性能下降。
而现在的设计每个线程自己有一个 map,访问自己的,效率肯定要高些。
ThreadLocalMap 有自己的独立实现,可以简单地将它的 key 视作 ThreadLocal,value
为代码中放入的值(实际上 key 并不是 ThreadLocal 本身,而是它的一个 弱引用)。
为代码中放入的值(实际上 key 并不是 ThreadLocal 本身,而是它的一个 弱引用)。
每个线程在往 ThreadLocal 里放值的时候,都会往自己的 ThreadLocalMap 里存,
读也是以 ThreadLocal 作为引用,在自己的 map 里找对应的 key,从而实现了 线程隔离。
读也是以 ThreadLocal 作为引用,在自己的 map 里找对应的 key,从而实现了 线程隔离。
ThreadLocalMap 有点类似 HashMap 的结构,只是 HashMap 是由数组+链表+红黑树实现的,而 ThreadLocalMap 由 Entry 数组实现。
我们还要注意 Entry, 它的 key 是 ThreadLocal<?> k ,继承自 WeakReference, 也就是我们常说的 弱引用类型。
我们还要注意 Entry, 它的 key 是 ThreadLocal<?> k ,继承自 WeakReference, 也就是我们常说的 弱引用类型。
GC 之后 key 是否为 null?
回应开头的那个问题, ThreadLocal 的 key 是弱引用,那么当 ThreadLocal.get() 的调用在发生 GC 之后,key 是否是 null?
为了搞清楚这个问题,我们需要搞清楚 Java 的四种引用类型
1. 强引用:我们常常 new 出来的对象就是强引用类型(把一个对象赋给一个引用变量,这个引用变量就是一个强引用),
只要强引用存在,对象没有被置null,垃圾回收器将 永远不会回收被引用的对象,哪怕内存不足的时候
只要强引用存在,对象没有被置null,垃圾回收器将 永远不会回收被引用的对象,哪怕内存不足的时候
2. 软引用:使用 SoftReference 修饰的对象被称为软引用,软引用指向的对象在 内存要溢出的时候被回收
3. 弱引用:使用 WeakReference 修饰的对象被称为弱引用,只要发生垃圾回收,若这个对象 只被弱引用指向,那么就会被回收
4. 虚引用:虚引用是最弱的引用,在 Java 中使用 PhantomReference 进行定义。虚引用中唯一的作用就是 用队列接收对象即将死亡的通知
接着再来看下代码,我们使用反射的方式来看看 GC 后 ThreadLocal 中的数据情况
如图所示,因为这里 创建的 ThreadLocal 并没有指向任何变量,也就是没有任何引用,referent=null,
所以这里在GC之后,key就会被回收
所以这里在GC之后,key就会被回收
如果我们改动一下代码,让创建的 ThreadLocal 指向一个变量,
那么 ThreadLocal 的 强引用存在,在 GC 之后,key 不会被回收
那么 ThreadLocal 的 强引用存在,在 GC 之后,key 不会被回收
如果没有过多思考,想到弱引用,还有垃圾回收,那么肯定会觉得 GC 过后 key 是 null。
如果我们的 强引用 不存在的话,key 才会被回收,导致 value 一直存在,出现内存泄漏。
如果我们的 强引用 不存在的话,key 才会被回收,导致 value 一直存在,出现内存泄漏。
key 为什么要设置成弱引用?
既然弱引用类型的 key 可能会造成内存泄漏,那为什么还要把 key 设置成弱引用?
我们知道,ThreadLocalMap 是和线程绑定的,因为每个线程都会有自己的一个 ThreadLocalMap(当创建了 ThreadLocal 时)。
从上图可以看出,ThreadLocal 被两种引用指向:
- ThreadLocalRef->ThreadLocal(强引用)
- ThreadLocalMap Entry key ->ThreadLocal(弱引用)
所以就算设置成弱引用,只要 ThreadLocal 没被回收(使用时强引用不置 null),
那 ThreadLocalMap Entry key 的指向就不会在 GC 时被回收。
那 ThreadLocalMap Entry key 的指向就不会在 GC 时被回收。
而如果设置成强引用,外界是通过 ThreadLocal 来对 ThreadLocalMap 进行操作的,如果外界使用的
ThreadLocal 对象被置为 null 了,那 ThreadLocalMap key 的强引用指向 ThreadLocal 为 null,
而强引用又不会被 GC 垃圾回收,内存泄漏的情况岂不是更糟糕??
ThreadLocal 对象被置为 null 了,那 ThreadLocalMap key 的强引用指向 ThreadLocal 为 null,
而强引用又不会被 GC 垃圾回收,内存泄漏的情况岂不是更糟糕??
所以 弱引用反而可以预防大多数内存泄漏的情况。毕竟被回收后,下一次调用 set/get/remove 时 ThreadLocal 内部会清除掉。
value为什么不设置成弱引用?
那照上面的说法,value 也应该设置成弱引用才对呀?不然不是一样会发生内存泄漏?
先说答案:假如 value 被设计成弱引用,那么很有可能当你需要取这个 value 值的时候,取出来的 值是一个 null。
如果我在使用 ThreadLocal(存在对 ThreadLocal 的强引用),而此时 entry 的 value 若是弱引用指向 object,value 除了
指向 object 外,是 没有任何引用指向它的,所以它肯定会 被 GC 清理,那我再取获取 value 的时候岂不是得到一个 null 值 ?
指向 object 外,是 没有任何引用指向它的,所以它肯定会 被 GC 清理,那我再取获取 value 的时候岂不是得到一个 null 值 ?
ThreadLocal.set() 源码初探
ThreadLocal中的set方法原理如下图所示,很简单,主要是判断ThreadLocalMap是否存在,
然后使用ThreadLocal中的set方法进行数据处理。
然后使用ThreadLocal中的set方法进行数据处理。
代码如下,主要核心逻辑还是在 ThreadLocalMap 中 的 set(),在下面的 Hash 算法中会分析。
ThreadLocalMap Hash 算法
如果当前 ThreadLocalMap 不为空,则会调用 ThreadLocalMap.set(this, value) 来设置值,会先计算 index
既然是 Map 结构,那么 ThreadLocalMap 当然也要实现自己的 hash 算法来解决散列表数组冲突问题。
和 HashMap 的 hash 算法相似,用当前的 HashCode 对 数组长度 len 取余(前提 len 为 2 的幂次方)。
既然是 Map 结构,那么 ThreadLocalMap 当然也要实现自己的 hash 算法来解决散列表数组冲突问题。
和 HashMap 的 hash 算法相似,用当前的 HashCode 对 数组长度 len 取余(前提 len 为 2 的幂次方)。
这里最关键的就是 threadLocalHashCode 值的计算,ThreadLocal 中有一个属性为 HASH_INCREMENT = 0x61c88647,
每当 创建一个 ThreadLocal 对象,这个 ThreadLocal.nextHashCode 这个值就会 增长 0x61c88647 。
每当 创建一个 ThreadLocal 对象,这个 ThreadLocal.nextHashCode 这个值就会 增长 0x61c88647 。
HASH_INCREMENT = 0x61c88647 这个值很特殊,它是斐波那契数 也叫 黄金分割数。
hash 增量为这个数字,带来的好处就是 hash 分布非常均匀。我们自己可以尝试下:
hash 增量为这个数字,带来的好处就是 hash 分布非常均匀。我们自己可以尝试下:
ThreadLocal Hash 冲突
注明:下面所有示例图中,绿色块 Entry 代表正常数据,灰色块代表 Entry 的 key 值为 null ,已被垃圾回收。白色块表示 Entry 为 null。
虽然 ThreadLocalMap 中使用了黄金分割数来作为 hash 计算因子,大大减少了 Hash 冲突的概率,但是仍然会存在冲突。
HashMap 中解决冲突的方法是在数组上构造一个链表结构,冲突的数据挂载到链表上,如果链表长度超过一定数量则会转化成红黑树。
而 ThreadLocalMap 中并没有链表结构,所以这里不能使用 HashMap 解决冲突的方式了。
而 ThreadLocalMap 中并没有链表结构,所以这里不能使用 HashMap 解决冲突的方式了。
如上图所示,如果我们插入一个 value=27 的数据,通过 hash 计算后应该落入槽位 4 中,而槽位 4 已经有了 Entry 数据。
此时就会 线性向后查找,一直找到 Entry 为 null 的槽位才会停止查找,将当前元素放入此槽位中。当然 迭代过程中还有其他的情况,
比如遇到了 Entry 不为 null 且 key 值相等的情况,还有 Entry 中的 key 值为 null 的情况等等都会有不同的处理,后面会一一详细讲解。
此时就会 线性向后查找,一直找到 Entry 为 null 的槽位才会停止查找,将当前元素放入此槽位中。当然 迭代过程中还有其他的情况,
比如遇到了 Entry 不为 null 且 key 值相等的情况,还有 Entry 中的 key 值为 null 的情况等等都会有不同的处理,后面会一一详细讲解。
这里还画了一个 Entry 中的 key 为 null 的数据(Entry=2 的灰色块数据),因为 key 值是弱引用类型,所以会有这种数据存在。
在 set 过程中,如果 遇到了 key 过期的 Entry 数据,实际上是会进行一轮 探测式清理 操作的,具体操作方式后面会讲到。
在 set 过程中,如果 遇到了 key 过期的 Entry 数据,实际上是会进行一轮 探测式清理 操作的,具体操作方式后面会讲到。
ThreadLocalMap.set() 详解
看完了 ThreadLocal hash 算法后,我们再来看 ThreadLocalMap 中的 set 是如何实现的。
往 ThreadLocalMap 中 set 数据(新增或者更新数据)分为好几种情况,先看图解
第一种情况: 通过 hash 计算后的槽位对应的 Entry 数据为空,则直接将数据放到该槽位即可。
第二种情况: 槽位数据不为空,key 值与当前 ThreadLocal 通过 hash 计算获取的
key 值一致,说明是更新数据,则直接更新该槽位的数据。
key 值一致,说明是更新数据,则直接更新该槽位的数据。
第三种情况: 槽位数据不为空,往后遍历过程中,在找到 Entry 为 null 的槽位之前,没有遇到 key 过期的Entry,
则遍历散列数组,线性往后查找,如果找到 Entry 为 null 的槽位,则将数据放入该槽位中;或者往后遍历过程中,
遇到了 key 值相等的数据,直接更新即可。
则遍历散列数组,线性往后查找,如果找到 Entry 为 null 的槽位,则将数据放入该槽位中;或者往后遍历过程中,
遇到了 key 值相等的数据,直接更新即可。
第四种情况: 槽位数据不为空,往后遍历过程中,在找到 Entry 为 null 的槽位之前,遇到 key 过期的 Entry,
如上图,往后遍历过程中,遇到了 index=7 的槽位数据 Entry 的 key=null。
散列数组下标为 7 位置对应的 Entry 数据 key 为 null,表明此数据 key 值已经被垃圾回收掉了,此时就会执行
replaceStaleEntry() 方法,该方法含义是 替换过期数据的逻辑,以 index=7 位起点开始遍历,进行 探测式数据清理。
第一步:
如上图,往后遍历过程中,遇到了 index=7 的槽位数据 Entry 的 key=null。
散列数组下标为 7 位置对应的 Entry 数据 key 为 null,表明此数据 key 值已经被垃圾回收掉了,此时就会执行
replaceStaleEntry() 方法,该方法含义是 替换过期数据的逻辑,以 index=7 位起点开始遍历,进行 探测式数据清理。
第一步:
- 初始化探测式清理过期数据扫描的开始位置为:slotToExpunge = staleSlot = 7
第二步:
理过期数据的起始下标 slotToExpunge 的值,这个值在后面会讲解,它是用来 判断当前过期槽位 staleSlot 之前是否还有过期元素。
接着开始 以 staleSlot 位置(index=7) 向后迭代 ————>
- 以 当前 staleSlot 开始向前迭代查找,找其他过期的数据,然后 更新过期数据起始扫描下标 slotToExpunge(注意并没有更新 staleSlot)。
理过期数据的起始下标 slotToExpunge 的值,这个值在后面会讲解,它是用来 判断当前过期槽位 staleSlot 之前是否还有过期元素。
接着开始 以 staleSlot 位置(index=7) 向后迭代 ————>
如果找到了相同 key 值的 Entry 数据,则表明要更新数据,则更新 Entry 的值并交换到当前
staleSlot 位置 (staleSlot位置为过期元素),更新 Entry 数据。更新后的状态图如右图 ——>
staleSlot 位置 (staleSlot位置为过期元素),更新 Entry 数据。更新后的状态图如右图 ——>
然后现在 才开始进行过期 Entry 的清理工作,从上面的 0 位置开始,向后清理,如下图所示:
如果没有找到相同 key 值的 Entry 数据,则继续往后直到 Entry 为 null 才停止寻找,
然后创建新的 Entry,替换到 table[stableSlot] 的位置
替换完成后也是进行过期元素清理工作,和上面的清理工作一样,清理工作主要是有两个方法:
expungeStaleEntry() 和 cleanSomeSlots(),具体细节后面会讲到。
expungeStaleEntry() 和 cleanSomeSlots(),具体细节后面会讲到。
往 ThreadLocalMap 中 set 数据(新增或者更新数据) 源码详解
通过了上面的图解 set() 实现原理,其实已经很清晰了,接下来分析下源码
首先会通过 key 来计算在散列表中的对应位置,然后以当前 key 对应的桶位置向后查找,找到可用的桶:
遇到 k = key 说明是更新操作,可以使用 碰到一个过期的桶,执行替换逻辑,占用过期桶 查找过程中,碰到桶中 Entry=null 的情况,直接使用
逻辑分析
1. 如果 key 值对应的桶中 Entry 数据不为空:
- 如果 k = key,说明当前 set 操作是一个替换更新操作,做替换逻辑,直接返回
- 如果 key = null,说明当前桶位置的 Entry 是过期数据,执行 replaceStaleEntry() 方法,然后返回
重点:replaceStaleEntry() 方法提供替换过期数据的功能,我们可以
对应上面图解的四种情况的原理图来再回顾下,具体代码如下:
对应上面图解的四种情况的原理图来再回顾下,具体代码如下:
slotToExpunge 表示开始探测式清理过期数据的 开始下标,默认从当前的 staleSlot 开始。
以当前的 staleSlot 开始,向前迭代查找,找到没有过期的数据,for 循环一直碰到 Entry
为 null 才会结束。如果 向前找到了过期数据,更新探测清理过期数据的开始下标为 i,即
slotToExpunge=i。(对应上面图解第二步)
以当前的 staleSlot 开始,向前迭代查找,找到没有过期的数据,for 循环一直碰到 Entry
为 null 才会结束。如果 向前找到了过期数据,更新探测清理过期数据的开始下标为 i,即
slotToExpunge=i。(对应上面图解第二步)
接着开始从 staleSlot 向后查找,也是碰到 Entry 为 null 的桶结束循环。 在迭代过程中:
- 如果碰到 k == key,这说明这里是 替换 逻辑,替换新数据并且交换到当前 staleSlot 位置。
如果 slotToExpunge == staleSlot,这说明 之前开始向前查找过期数据时并未找到过期的
Entry 数据,接着 向后查找过程中也未发现过期数据,修改开始探测式清理过期数据的下标
为当前循环的 index,即slotToExpunge = i。最后调用 cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
进行启发式过期数据清理,然后返回。
- 如果 k != key,则会接着往下走,k == null 说明 当前遍历的 Entry 是一个过期数据,slotToExpunge == staleSlot
说明一开始的 向前查找数据并未找到过期的 Entry。如果条件成立,则 更新 slotToExpunge 为当前位置。
往后迭代的过程中如果没有找到 k == key 的数据,且 碰到 Entry 为 null 的数据,则结束 for 循环。
此时说明这里是一个 添加 的逻辑,将 新创建的数据替换到 staleSlot 位置上。
最后判断除了 staleSlot 以外,如果 之前还发现了其他过期的 slot 数据,就要 开启清理数据 的逻辑。
( 对应上面图解第四步 )
此时说明这里是一个 添加 的逻辑,将 新创建的数据替换到 staleSlot 位置上。
最后判断除了 staleSlot 以外,如果 之前还发现了其他过期的 slot 数据,就要 开启清理数据 的逻辑。
( 对应上面图解第四步 )
2. 遍历当前 key 值对应的桶中 Entry 数据为 null,这说明散列数组这里没有数据冲突,跳出 for 循环:
- 在 Entry 为 null 的桶中创建一个新的 Entry 对象,再执行 ++size 操作
3. 调用 cleanSomeSlots() 做一次启发式清理工作,清理散列数组中 Entry 的 key 过期的数据:
- 如果清理工作完成后,未清理到任何数据,且 size 超过了阈值(threshold 为 len * 2/3),
进行 rehash() 操作,后面会详解。
ThreadLocalMap.set() 流程图:
replaceStaleEntry() 流程图:
过期 key 的清理流程
上面我们有提及 ThreadLocalMap 的两种过期 key 数据清理方式:探测式清理和启发式清理。
探测式清理 expungeStaleEntry() 方法
图解
遍历散列数组,从 slotToExpunge 开始位置向后探测清理 过期数据:
- 如果 遇到过期的 key,则将 该位置的 Entry 设置为 null,
- 如果沿途中 碰到未过期的数据,先将此数据 rehash 后重新在 table 数组中定位,如果定位的位置已经有了数据,
则会 将未过期的数据放到最靠近此位置的 Entry=null 的桶中,使 rehash 后的 Entry 数据距离正确的桶位置更近一些。
为什么重新 rehash 后该数据会离正确的桶位置更近一些?
重新计算的新下标不在正确的位置,说明之前插入该数据的时候发生了哈希冲突,使得该数据的位置往后移了,
所以现在重新计算下标,将数据放在离正确的桶更近一些的位置上。(因为它前面可能已经有数据被清理了,桶为空了)
重新计算的新下标不在正确的位置,说明之前插入该数据的时候发生了哈希冲突,使得该数据的位置往后移了,
所以现在重新计算下标,将数据放在离正确的桶更近一些的位置上。(因为它前面可能已经有数据被清理了,桶为空了)
示例:现在我们进行一个 set(27) 的操作,set(27) 经过 hash 计算后应该落到 index=4 的桶中,
假设 index=4 桶已经有了数据,所以往后迭代最终数据放入到 index=7 (空槽)的桶中。
一段时间后,index=5 种的 Entry 数据 key 变为了 null,如下图:
假设 index=4 桶已经有了数据,所以往后迭代最终数据放入到 index=7 (空槽)的桶中。
一段时间后,index=5 种的 Entry 数据 key 变为了 null,如下图:
此时执行探测式清理:index=5 的数据被清理掉,继续往后迭代,到 index=7 的元素时,经过 rehash 后发现该
元素正确的 index=4,而此位置已经有了数据,往后查找离 index=4 最近的 Entry=null 的节点 (刚被探测式清理
掉的数据:index=5),找到后移动 index= 7 的数据到 index=5 中,此时桶的位置离正确的位置 index=4 更近了。
元素正确的 index=4,而此位置已经有了数据,往后查找离 index=4 最近的 Entry=null 的节点 (刚被探测式清理
掉的数据:index=5),找到后移动 index= 7 的数据到 index=5 中,此时桶的位置离正确的位置 index=4 更近了。
在往后迭代的过程中 碰到空的槽位,终止探测,这样一轮探测式清理工作就完成了。
经过一轮探测式清理后,key 过期的数据会被清理掉,没过期的数据经过 rehash 重定位后所处的桶位置
理论上更接近 i = key.hashCode & (tab.len - 1) 的位置。这种优化会 提高整个散列表查询性能。
经过一轮探测式清理后,key 过期的数据会被清理掉,没过期的数据经过 rehash 重定位后所处的桶位置
理论上更接近 i = key.hashCode & (tab.len - 1) 的位置。这种优化会 提高整个散列表查询性能。
源码
整体源码实现:
这里是处理正常的产生 Hash 冲突的数据,经过迭代后,有过 Hash 冲突数据的 Entry 位置会更靠近正确位置,这样的话,查询的时候效率才会更高。
流程图:
启发式清理 clearSomeSlots() 方法
探测式清理 是 以当前 Entry 往后清理,遇到值为 null 则结束清理,属于线性探测清理。
而 启发式清理 被作者定义为:Heuristically scan some cells looking for stale entries.
意为:启发式地扫描一些单元格以查找过时的 entry。
而 启发式清理 被作者定义为:Heuristically scan some cells looking for stale entries.
意为:启发式地扫描一些单元格以查找过时的 entry。
通过源码可以看到,启发式清理 就是 当没有找到要清理的脏数据(entry 的 key 为 null,value 不为 null)时,
每次将 清理的范围缩小一半;一旦找到一个脏数据,清理的范围又变为原来的 len,且执行一次探测式清理。
每次将 清理的范围缩小一半;一旦找到一个脏数据,清理的范围又变为原来的 len,且执行一次探测式清理。
图解:
流程图:
扩容机制
在 ThreadLocalMap.set() 方法的最后,如果执行完启发式清理工作后,未清理到任何数据,
且当前 散列数组中 Entry 的数量 已经达到了列表的 扩容阈值 (threshold 为 len * 2/3),就开始执行 rehash() 逻辑:
且当前 散列数组中 Entry 的数量 已经达到了列表的 扩容阈值 (threshold 为 len * 2/3),就开始执行 rehash() 逻辑:
rehash() 具体实现,首先是会进行探测式清理工作,从 table 的 起始位置往后清理。清理完成之后,table 中 可能有一些 key 为 null 的
Entry 数据被清理掉,所以此时通过判断 size >= threshold - threshold / 4 也就是 size >= threshold * 3/4 来决定是否扩容。
Entry 数据被清理掉,所以此时通过判断 size >= threshold - threshold / 4 也就是 size >= threshold * 3/4 来决定是否扩容。
我们还记得上面进行 rehash() 的阈值是 size >= threshold,所以对于
ThreadLocalMap 的扩容机制,我们一定要弄清楚这两个步骤。
ThreadLocalMap 的扩容机制,我们一定要弄清楚这两个步骤。
第一个阈值是 sz >= threshold,threshold 为 len * 2/3,超过此阈值才会进行 rehash()
第二个阈值是 size >= threshold * 3/4,threshold 为 len * 2/3,所以 size >= len * 2/3 * 3/4 时进行 resize() 扩容
接着来看看 resize() 扩容方法
源码:
示例:为了方便演示,以 oldTab.len=8 为例。
扩容后的 tab 的大小为 oldLen * 2,然后遍历老的散列表,重新计算 hash 位置,然后 放到新的 tab 数组中,如果出现 hash 冲突则往后寻找
最近的 entry 为 null 的槽位,遍历完成之后,oldTab 中所有的 entry 数据都已经放入到新的 tab 中了。重新计算 tab 下次扩容的阈值。
扩容后的 tab 的大小为 oldLen * 2,然后遍历老的散列表,重新计算 hash 位置,然后 放到新的 tab 数组中,如果出现 hash 冲突则往后寻找
最近的 entry 为 null 的槽位,遍历完成之后,oldTab 中所有的 entry 数据都已经放入到新的 tab 中了。重新计算 tab 下次扩容的阈值。
整体流程:
ThreadLocalMap.get() 详解
ThreadLocal 调用 get() 方法,可以看到,和set方法有些类似,
都是通过线程 Thread 来获取当前线程来确定 ThreadLocalMap。
都是通过线程 Thread 来获取当前线程来确定 ThreadLocalMap。
ThreadLocalMap 为空的情况,调用了 setInitalValue();
可以看到这里就跟 set() 方法类似了,首先判断 map 是否为空,如果为空就调用 createMap 方法,
包括(新创建一个数组,设置扩容的阈值,等等操作),但是如果 map 不等于空 的情况下,为什么又
要 调用 set() 方法 呢? 答案其实都在 set() 方法里面,可能会进行 探测式清理脏数据, rehash() 等操作。
可以看到这里就跟 set() 方法类似了,首先判断 map 是否为空,如果为空就调用 createMap 方法,
包括(新创建一个数组,设置扩容的阈值,等等操作),但是如果 map 不等于空 的情况下,为什么又
要 调用 set() 方法 呢? 答案其实都在 set() 方法里面,可能会进行 探测式清理脏数据, rehash() 等操作。
ThreadLocalMap 不为空的时候,调用 ThreadLocalMap.getEntry() 方法,操作如下:
getEntryAfterMiss():
图解
第一种情况: 通过查找 key 值计算出散列表中 slot 位置,然后该 slot 位置中的 Entry.key 和查找的 key 一致,则直接返回:
第二种情况: slot 位置中的 Entry.key 和要查找的 key 不一致:
以 get(ThreadLocal1) 为例,通过 hash 计算后,正确的 slot 位置应该是 4,而 index=4
的槽位已经有了数据,且 key 值不等于 ThreadLocal1,所以需要继续往后迭代查找。
迭代到 index=5 的数据时,此时 Entry.key=null,触发一次探测式数据回收 操作,执行 expungeStaleEntry() 方法,
执行完后,index = 5, index = 8 位置的数据都会被回收,而 index = 6, index = 7 的数据都会前移,前移之后,
继续从 index=5 往后迭代,于是就在 index=5 找到了 key 值相等的 Entry 数据,如下图所示:
的槽位已经有了数据,且 key 值不等于 ThreadLocal1,所以需要继续往后迭代查找。
迭代到 index=5 的数据时,此时 Entry.key=null,触发一次探测式数据回收 操作,执行 expungeStaleEntry() 方法,
执行完后,index = 5, index = 8 位置的数据都会被回收,而 index = 6, index = 7 的数据都会前移,前移之后,
继续从 index=5 往后迭代,于是就在 index=5 找到了 key 值相等的 Entry 数据,如下图所示:
InheritableThreadLocal 类
InheritableThreadLocal 类继承了 ThreadLocal 类,主要功能为:
- 对于 InheritableThreadLocal 类型变量的值,在子线程中可以获取到;
- 而对于 ThreadLocal 类型变量的值,在子线程中不能获取到;
通过输出,对于 ThreadLocal 我们已经有了详尽的分析,在 Thread 中有一个 Map,以 ThreadLocal 类型的变量作为 key。
在子线程中,get 方法自然是获取子线程中的 Map,很显然,子线程中的 Map 并没有设置任何值,所以初始值 null 被读取
出来;但是,InheritableThreadLocal 为什么能够读取出来?
在子线程中,get 方法自然是获取子线程中的 Map,很显然,子线程中的 Map 并没有设置任何值,所以初始值 null 被读取
出来;但是,InheritableThreadLocal 为什么能够读取出来?
原理分析
在 Thread 类中,有一个 ThreadLocal.ThreadLocalMap 类型的变量 inheritableThreadLocals,
他的类型同 Thread 内部的 threadLocals 变量。
他的类型同 Thread 内部的 threadLocals 变量。
在 Thread 的 init 方法中,有一段初始化设置(这个 init 方法是所有的 Thread 对象创建的必经之路)
createInheritedMap() 方法,创建了一个 ThreadLocalMap,参数为父线程的 ThreadLocalMap
创建 ThreadLocalMap 就是根据入参的父线程的 ThreadLocalMap,拷贝创建一份。
从上面的这个方法可以很清晰的看得到,如果 parent 的 inheritableThreadLocals 不是 null,
那么就会将当前线程的 inheritableThreadLocals 设置为 parent 的 inheritableThreadLocals。
所以借助于 inheritableThreadLocals,可以实现,创建线程向被创建线程数据传递
那么就会将当前线程的 inheritableThreadLocals 设置为 parent 的 inheritableThreadLocals。
所以借助于 inheritableThreadLocals,可以实现,创建线程向被创建线程数据传递
但 InheritableThreadLocal 仍然有缺陷,一般我们做异步化处理都是使用的线程池,线程池是线程复用的逻辑,
而 InheritableThreadLocal 是在 new Thread 中的 init() 方法给赋值的,所以这里会存在问题。
而 InheritableThreadLocal 是在 new Thread 中的 init() 方法给赋值的,所以这里会存在问题。
当然,有问题出现就会有解决问题的方案,阿里巴巴开源了一个 TransmittableThreadLocal 组件就可以解决这个问题
ThreadLocal 使用时的注意事项
ThreadLocal 的使用场景
数据库连接
在多线程的情况下,可能一个线程要处理多个客户端的请求,如果每个客户端都要求连接相同的数据库,此时使用 ThreadLocal 再合适不过了。
若不使用 ThreadLocal,同一个线程处理多个客户端时,需要为每个客户端都创建一个数据库连接,数据库的连接创建和断开都是需要耗时的。
这时你可能回想,创建一个全局的连接嘛,多个线程都公用一个。但是这样会有个问题,每个线程使用完数据库连接后,都应该关闭掉该连接
以减少内存消耗。如果使用全局变量,那到底什么时候关闭连接呢?万一某个线程关闭了,还有其他线程需要使用呢?也不可能一直不关闭吧?
以减少内存消耗。如果使用全局变量,那到底什么时候关闭连接呢?万一某个线程关闭了,还有其他线程需要使用呢?也不可能一直不关闭吧?
若使用 ThreadLocal,一个线程只需要创建一个数据库连接,这个线程处理其他客户端时可以直接复用此连接,避免了创建连接的消耗。
同时,若此线程处理完了所有的请求,就关闭自己的连接即可,不会影响到其他的线程。
同时,若此线程处理完了所有的请求,就关闭自己的连接即可,不会影响到其他的线程。
保存线程不安全的工具类
若某个工具类在并发的场景下会有线程不安全的情况,那么可以使用 ThreadLocal。
使用 SimpleDateFormat 来举例,它在多线程下是不安全的,因为它底层没有加任何锁。
假设我们需要对 1000 个时间进行格式化,如果我们在每个线程处理时间的时候都创建一个 SimpleDateFormat,
那么就需要创建 1000 个 SimpleDateFormat,这会有一定的开销。
那么就需要创建 1000 个 SimpleDateFormat,这会有一定的开销。
那创建一个全局变量 SimpleDateFormat,所有线程都使用这个 SimpleDateFormat 进行时间的格式化。
但是,SimpleDateFormat 是线程不安全的,这将意味着可能有多个线程会同时访问一个 SimpleDateFormat,
这样在返回格式化后的时间时,可能存在一个线程把之前线程格式化完的时间覆盖了,最后这 1000 个时间里
会有一些时间没有被格式化到。也可以在每次格式化时加锁,但是这样效率很低。
但是,SimpleDateFormat 是线程不安全的,这将意味着可能有多个线程会同时访问一个 SimpleDateFormat,
这样在返回格式化后的时间时,可能存在一个线程把之前线程格式化完的时间覆盖了,最后这 1000 个时间里
会有一些时间没有被格式化到。也可以在每次格式化时加锁,但是这样效率很低。
我们希望达到的效果是,既不浪费过多的内存,同时又想保证线程安全。经过思考得出,
可以让每个线程都拥有一个自己的 simpleDateFormat 对象来达到这个目的,这样就能两全其美了。
一个线程格式化完一个时间后,再去格式化下一个时间,而不同线程之间的 simpleDateFormat 又是隔离的,
所以这个线程不可能会格式化到另一个线程正在格式化的时间。如下示例,输出格式化后 1000 个不同的时间:
可以让每个线程都拥有一个自己的 simpleDateFormat 对象来达到这个目的,这样就能两全其美了。
一个线程格式化完一个时间后,再去格式化下一个时间,而不同线程之间的 simpleDateFormat 又是隔离的,
所以这个线程不可能会格式化到另一个线程正在格式化的时间。如下示例,输出格式化后 1000 个不同的时间:
每个线程内都都需要独立的保存信息
ThreaLocal 作用在每个线程内都都需要独立的保存信息,这样就方便同一个线程的其他方法获取到该信息的场景。
由于每一个线程获取到的信息可能都是不一样的,前面执行的方法保存了信息之后,后续方法可以通过 ThreadLocal
可以直接获取到,避免了传参,这个类似于全局变量的概念。
可以直接获取到,避免了传参,这个类似于全局变量的概念。
就好比如线程 A 的方法一创建了变量 A,方法二是跟方法一在同一个线程内,那么创建的变量 A 就是共享的。
最常用的就是保存用户信息,然后在其他方法中可以直接获取到该用户。
避免线程复用(线程池)时的脏数据
在使用线程池的情况下,会有线程复用的情况,一个线程可能会处理多个任务。
由于线程池会复用 Thread 对象,因此 Thread 类的成员变量 threadLocals(ThreadLocalMap)也会被复用。
如果在一个线程处理完当前任务后,忘记将 threadlocals 进行清理 remove(),并且这个线程在处理下一个任务时,
不调用 set() 设置初始值(调用 set() 会将之前 ThreadLocal key 对应的 value 修改掉),那么这时也能获取到这个 threadlocals。
例如,thread-1 在处理下一个任务时,能获取到上一个任务中 ThreadLocal 的值:
不调用 set() 设置初始值(调用 set() 会将之前 ThreadLocal key 对应的 value 修改掉),那么这时也能获取到这个 threadlocals。
例如,thread-1 在处理下一个任务时,能获取到上一个任务中 ThreadLocal 的值:
所以,在线程复用的情况下,一定要注意及时进行 threadlocals 的清理工作。
内存泄漏问题
ThreadLocalMap 使用 ThreadLocal 的弱引用作为 key,如果一个 ThreadLocal 没有外部强引用来引用它,那么系统 GC 的时候,
这个 ThreadLocal 势必会被回收,这样一来,ThreadLocalMap 中就会出现 key 为 null 的Entry,就没有办法访问这些 key 为 null
的 Entry 的 value,如果当前线程再迟迟不结束的话,这些 key 为 null 的 Entry 的 value 就会一直存在一条强引用链:
Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value 永远无法回收,造成 内存泄漏
这个 ThreadLocal 势必会被回收,这样一来,ThreadLocalMap 中就会出现 key 为 null 的Entry,就没有办法访问这些 key 为 null
的 Entry 的 value,如果当前线程再迟迟不结束的话,这些 key 为 null 的 Entry 的 value 就会一直存在一条强引用链:
Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value 永远无法回收,造成 内存泄漏
其实,ThreadLocalMap 的设计中已经考虑到这种情况,也加上了一些防护措施:
- 在ThreadLocal的 get(),set(),remove() 的时候都会清除线程 ThreadLocalMap 里所有 key 为 null 的 value。
解决以上两个问题的办法很简单,就是在每次用完 ThreadLocal 后,及时调用 remove() 方法清理即可。
0 条评论
下一页