java高并发核心编程
第一章 多线程原理与实战
1.2 无处不在的进程和线程
什么是进程呢?简单来说,进程是程序的一次启动执行。什么是程序呢?程序是存放在硬盘中的可执行文件,主要包括代码指令和数据。一个进程是一个程序的一次启动和执行,是操作系统将程序装入内存,给程序分配必要的系统资源,并且开始运行程序的指令。
进程与程序是什么关系呢?同一个程序可以多次启动,对应多个进程。比如,多次打开Chrome浏览器程序,在ProcessExplorer中可以看到多个Chrome浏览器进程。
1.2.1 进程的基本原理
进程的定义一直以来没有完美的标准。一般来说,一个进程由程序段、数据段和进程控制块三部分组成。进程的大致结构如图1-2所示。
(Program Control Block,PCB)包含进程的描述信息和控制信息,是进程存在的唯一标志。
PCB主要由四大部分组成:
(1)进程的描述信息。主要包括:进程ID和进程名称,进程ID是唯一的,代表进程的身份;进程状态,比如运行、就绪、阻塞;进程优先级,是进程调度的重要依据。
(2)进程的调度信息。主要包括:程序起始地址,程序的第一行指令的内存地址,从这里开始程序的执行;通信信息,进程间通信时的消息队列。
(3)进程的资源信息。主要包括:内存信息,内存占用情况和内存管理所用的数据结构;I/O设备信息,所用的I/O设备编号及相应的数据结构;文件句柄,所打开文件的信息。
(4)进程上下文。主要包括执行时各种CPU寄存器的值、当前程序计数器(PC)的值以及各种栈的值等,即进程的环境。在操作系统切换进程时,当前进程被迫让出CPU,当前进程的上下文就保存在PCB结构中,供下次恢复运行时使用。
对Java工程师来说,这里有一个问题:什么是Java程序的进程呢?
Java编写的程序都运行在Java虚拟机(JVM)中,每当使用Java命令启动一个Java应用程序时,就会启动一个JVM进程。在这个JVM进程内部,所有Java程序代码都是以线程来运行的。JVM找到程序的入口点main()方法,然后运行main()方法,这样就产生了一个线程,这个线程被称为主线程。当main()方法结束后,主线程运行完成,JVM进程也随即退出。
1.2.2 线程的基本原理
Java程序的进程执行过程就是标准的多线程的执行过程。每当使用Java命令执行一个class类时,实际上就是启动了一个JVM进程。理论上,在该进程的内部至少会启动两个线程,一个是main线程,另一个是GC(垃圾回收)线程。实际上,执行一个Java程序后,通过Process Explorer来观察,线程数量远远不止两个,达到了18个之多。
在Java中,执行程序流程的重要单位是“方法”,而栈内存的分配单位是“栈帧”(或者叫“方法帧”)。方法的每一次执行都需要为其分配一个栈帧(方法帧),栈帧主要保存该方法中的局部变量、方法的返回地址以及其他方法的相关信息。当线程的执行流程进入方法时,JVM就会为方法分配一个对应的栈帧压入栈内存;当线程的执行流程跳出方法时,JVM就从栈内存弹出该方法的栈帧,此时方法帧的局部变量的内存空间就会被回收。
main方法下有两个方法,压栈出栈的原理:
image-20211104233858604
1.3 创建线程的4种方法
1.3.1 Thread类详解
image-20211104234031988
1.线程ID
属性:private long tid
方法:public long getId()
2.线程名称
属性:private String name
方法一:public final String getName()
方法二:public final void setName(String name)
方法三:Thread(String threadName) 构造方法
3.线程优先级
属性:private int priority
方法一:public final int getPriority()
方法二:public final void setPriority(int priority)
Java线程的最大优先级值为10,最小值为1,默认值为5。
4.是否为守护线程
属性:private boolean daemon=false,该属性保存Thread线程实例的守护状态,默认为false,表示是普通的用户线程,而不是守护线程。
方法:public final void setDaemon(boolean on),将线程实例标记为守护线程或用户线程,如果参数值为true,那么将线程实例标记为守护线程。
5.线程的状态
属性:private int threadStatus,该属性以整数的形式保存线程的状态。
方法:public Thread.State getState(),返回表示当前线程的执行状态,为新建、就绪、运行、阻塞、结束等状态中的一种。
Thread的内部静态枚举类State用于定义Java线程的所有状态,具体如下:
public static enum State{
NEW,// 新建`
`RUNNING,//就绪,运行
BLOCKED,
WATING, //等待
TIME_WATING, //计时等待
TEMINATED;//结束
}
6.线程的启动和运行
方法一:public void start()
方法二:public void run(),作为线程代码逻辑的入口方法。run()方法不是由用户程序来调用的,当调用start()方法启动一个线程之后,只要线程获得了CPU执行时间,便进入run()方法体去执行具体的用户线程代码。
7.取得当前线程
方法:public static Thread currentThread(),该方法是一个非常重要的静态方法,用于获取当前线程的Thread实例对象。
1.3.2 创建一个空线程
1.3.3 线程创建方法一:继承Thread类创建线程类
1.3.4 线程创建方法二:实现Runnable接口创建线程目标类
Thread类有一系列的构造器,其中有多个构造器可以为target属性赋值,这些构造器包括如下两个:
(1)public Thread(Runnable target)
(2)public Thread(Runnable target,String name)
使用这两个构造器传入target执行目标实例(Runnable实例),就可以直接通过Thread类的run()方法以默认方式实现,达到线程并发执行的目的。在这种场景下,可以不通过继承Thread类实现线程类的创建。
1.Runnable接口
@FunctionalInterface
public interface Runnable {
public abstract void run();
}
2.通过实现Runnable接口创建线程类
创建线程的第二种方法就是实现Runnable接口,将需要异步执行的业务逻辑代码放在Runnable实现类的run()方法中,将Runnable实例作为target执行目标传入Thread实例。该方法的具体步骤如下:
(1)定义一个新类实现Runnable接口。
(2)实现Runnable接口中的run()抽象方法,将线程代码逻辑存放在该run()实现版本中。
(3)通过Thread类创建线程对象,将Runnable实例作为实际参数传递给Thread类的构造器,由Thread构造器将该Runnable实例赋值给自己的target执行目标属性。
(4)调用Thread实例的start()方法启动线程。
(5)线程启动之后,线程的run()方法将被JVM执行,该run()方法将调用target属性的run()方法,从而完成Runnable实现类中业务代码逻辑的并发执行。
thread类的构造方法就是为Runnable设计的:
(1)public Thread(Runnable target)
(2)public Thread(Runnable target,String name)
(3)public Thread(ThreadGroup group,Runnable target)
1.3.5 优雅创建Runnable线程目标类的两种方式
使用Runnable创建线程目标类除了直接实现Runnable接口之外,还有两种比较优雅的代码组织方式:
(1)通过匿名类优雅地创建Runnable线程目标类。
(2)使用Lambda表达式优雅地创建Runnable线程目标类。
1.3.6 通过实现Runnable接口的方式创建线程目标类的优缺点
通过实现Runnable接口的方式创建线程目标类有以下缺点:
(1)所创建的类并不是线程类,而是线程的target执行目标类,需要将其实例作为参数传入线程类的构造器,才能创建真正的线程。
(2)如果访问当前线程的属性(甚至控制当前线程),不能直接访问Thread的实例方法,必须通过Thread.currentThread()获取当前线程实例,才能访问和控制当前线程。
通过实现Runnable接口的方式创建线程目标类有以下优点:
(1)可以避免由于Java单继承带来的局限性。如果异步逻辑所在类已经继承了一个基类,就没有办法再继承Thread类。比如,当一个Dog类继承了Pet类,再要继承Thread类就不行了。所以在已经存在继承关系的情况下,只能使用实现Runnable接口的方式。
(2)逻辑和数据更好分离。通过实现Runnable接口的方法创建多线程更加适合同一个资源被多段业务逻辑并行处理的场景。在同一个资源被多个线程逻辑异步、并行处理的场景中,通过实现Runnable接口的方式设计多个target执行目标类可以更加方便、清晰地将执行逻辑和数据存储分离,更好地体现了面向对象的设计思想。
1.3.7 线程创建方法三:使用Callable和FutureTask创建线程
一种新的多线程创建方法:通过Callable接口和FutureTask类相结合创建线程。
1.Callable接口
@FunctionalInterface
public interface Callable {
V call() throws Exception;
}
Callable接口是一个泛型接口,也是一个“函数式接口”。其唯一的抽象方法call()有返回值,返回值的类型为Callable接口的泛型形参类型。call()抽象方法还有一个Exception的异常声明,容许方法的实现版本的内部异常直接抛出,并且可以不予捕获。
2.RunnableFuture接口
这个重要的中间搭桥接口就是RunnableFuture接口,该接口与Runnable接口、Thread类紧密相关。与Callable接口一样,RunnableFuture接口也位于java.util.concurrent包中,使用的时候需要用import导入。
public interface RunnableFuture extends Runnable, Future {
void run();
}
RunnableFuture继承了Runnable接口,从而保证了其实例可以作为Thread线程实例的target目标;同时,RunnableFuture通过继承Future接口,保证了可以获取未来的异步执行结果。
3.Future接口
Future接口至少提供了三大功能:
(1)能够取消异步执行中的任务。
(2)判断异步任务是否执行完成。
(3)获取异步任务完成后的执行结果。
public interface Future {
boolean cancel(boolean mayInterruptIfRunning);//取消异步任务
boolean isCancelled();
boolean isDone();// 判断异步任务是否完成
V get() throws InterruptedException, ExecutionException;
V get(long timeout, TimeUnit unit)
throws InterruptedException, ExecutionException, TimeoutException;
}
对Future接口的主要方法详细说明如下:
·V get():获取异步任务执行的结果。注意,这个方法的调用是阻塞性的。如果异步任务没有执行完成,异步结果获取线程(调用线程)会一直被阻塞,一直阻塞到异步任务执行完成,其异步结果返回给调用线程。
·V get(Long timeout,TimeUnit unit):设置时限,(调用线程)阻塞性地获取异步任务执行的结果。该方法的调用也是阻塞性的,但是结果获取线程(调用线程)会有一个阻塞时长限制,不会无限制地阻塞和等待,如果其阻塞时间超过设定的timeout时间,该方法将抛出异常,调用线程可捕获此异常。
·boolean isDone():获取异步任务的执行状态。如果任务执行结束,就返回true。
·boolean isCancelled():获取异步任务的取消状态。如果任务完成前被取消,就返回true。
·boolean cancel(boolean mayInterruptRunning):取消异步任务的执行。
4.FutureTask类
FutureTask类是Future接口的实现类,提供了对异步任务的操作的具体实现。但是,FutureTask类不仅实现了Future接口,还实现了Runnable接口,或者更加准确地说,FutureTask类实现了RunnableFuture接口。
FutureTask代码实现的过程如下:
1.FutureTask内部有一个Callable类型的成员——callable实例属性,具体如下:private Callable<T>callable;
2.FutureTask类实现了Runnable接口,在其run()方法的实现版本中会执行callable成员的call()方法。将结果保存在outcome内;private Object outcome;
3.在FutureTask类的run()方法完成callable成员的call()方法的执行之后,其结果将被保存在outcome实例属性中,供FutureTask类的get()方法获取。
源代码如下:
public void run() {
if (state != NEW ||
!UNSAFE.compareAndSwapObject(this, runnerOffset,
null, Thread.currentThread()))
return;
try {
Callable c = callable;
if (c != null && state == NEW) {
V result;
boolean ran;
try {
result = c.call();
ran = true;
} catch (Throwable ex) {
result = null;
ran = false;
setException(ex);
}
if (ran)
set(result);
}
} finally {
// runner must be non-null until state is settled to
// prevent concurrent calls to run()
runner = null;
// state must be re-read after nulling runner to prevent
// leaked interrupts
int s = state;
if (s >= INTERRUPTING)
handlePossibleCancellationInterrupt(s);
}
}
protected void set(V v) {
if (UNSAFE.compareAndSwapInt(this, stateOffset, NEW, COMPLETING)) {
outcome = v;
UNSAFE.putOrderedInt(this, stateOffset, NORMAL); // final state
finishCompletion();
}
}
private V report(int s) throws ExecutionException {
Object x = outcome;
if (s == NORMAL)
return (V)x;
if (s >= CANCELLED)
throw new CancellationException();
throw new ExecutionException((Throwable)x);
}
public V get() throws InterruptedException, ExecutionException {
int s = state;
if (s <= COMPLETING)
s = awaitDone(false, 0L);
return report(s);
}
代码运行的整体过程就是:
image-20211105224124116
1.3.8 线程创建方法四:通过线程池创建线程
前面的示例中,所创建的Thread实例在执行完成之后都销毁了,这些线程实例都是不可复用的。实际上创建一个线程实例在时间成本、资源耗费上都很高(稍后会介绍),在高并发的场景中,断然不能频繁进行线程实例的创建与销毁,而是需要对已经创建好的线程实例进行复用,这就涉及线程池的技术。Java中提供了一个静态工厂来创建不同的线程池,该静态工厂为Executors工厂类。
ExecutorService是Java提供的一个线程池接口,每次我们在异步执行target目标任务的时候,可以通过ExecutorService线程池实例去提交或者执行。ExecutorService实例负责对池中的线程进行管理和调度,并且可以有效控制最大并发线程数,提高系统资源的使用率,同时提供定时执行、定频执行、单线程、并发数控制等功能。
向ExecutorService线程池提交异步执行target目标任务的常用方法有:
image-20211105224250844
1.4 线程的核心原理
现代操作系统(如Windows、Linux、Solaris)提供了强大的线程管理能力,Java不需要再进行独立的线程管理和调度,而是将线程调度工作委托给操作系统的调度进程去完成。在某些系统(比如Solaris操作系统)上,JVM甚至将每个Java线程一对一地对应到操作系统的本地线程,彻底将线程调度委托给操作系统。
1.4.1 线程的调度与时间片
线程的调度模型目前主要分为两种:分时调度模型和抢占式调度模型。
(1)分时调度模型:系统平均分配CPU的时间片,所有线程轮流占用CPU,即在时间片调度的分配上所有线程“人人平等”。
(2)抢占式调度模型:系统按照线程优先级分配CPU时间片。优先级高的线程优先分配CPU时间片,如果所有就绪线程的优先级相同,那么会随机选择一个,优先级高的线程获取的CPU时间片相对多一些。
1.4.2 线程的优先级
在Thread类中有一个实例属性和两个实例方法,专门用于进行线程优先级相关的操作。与线程优先级相关的成员属性为:
private int priority;
与Thread类线程优先级相关的实例方法为:
方法1:public final int getPriority(),获取线程优先级。
方法2:public final void setPriority(int priority),设置线程优先级。
Thread实例的priority属性默认是级别5,对应的类常量是NORM_PRIORITY。优先级最大值为10,最小值为1
1.4.3 线程的生命周期
Java中线程的生命周期分为6种状态。Thread类有一个实例属性和一个实例方法专门用于保存和获取线程的状态。其中,用于保存线程Thread实例状态的实例属性为:
private int status;
Thread类用于获取线程状态的实例方法为:
private Thread.State getState();
Thread.State是一个内部枚举类,定义了6个枚举常量,分别代表Java线程的6种状态,具体如下:
public enum State {
NEW,//创建成功但是没有调用start()方法启动的Thread线程实例都处于NEW状态
RUNNABLE,//当Java线程的Thread实例的start()方法被调用后,操作系统中的对应线程进入的并不是运行状态,而是就绪状态
BLOCKED,
WAITING,
TIMED_WAITING,//线程处于一种特殊的等待状态,准确地说,线程处于限时等待状态。能让线程处于限时等待状态的操作大致有以下几种:(1)Thread.sleep(int n):使得当前线程进入限时等待状态,等待时间为n毫秒。(2)Object.wait():带时限的抢占对象的monitor锁。(3)Thread.join():带时限的线程合并。(4)LockSupport.parkNanos():让线程等待,时间以纳秒为单位。(5)LockSupport.parkUntil():让线程等待,时间可以灵活设置。
TERMINATED;//处于RUNNABLE状态的线程在run()方法执行完成之后就变成终止状态TERMINATED了。
}
整体过程如下图:
image-20211105225033241
1.4.5 使用Jstack工具查看线程状态
有时,服务器CPU占用率会一直很高,甚至一直处于100%。如果CPU使用率居高不下,自然是有某些线程一直占用着CPU资源。那如何查看占用CPU较高的线程呢?或者说,如何查看线程的状态呢?一种比较快捷的办法是使用Jstack工具。
Jstack命令的语法格式如下:
jstack <pid> pid是java线程的Id,通过jps命令可以查看
查出来的结果如下:
image-20211105225213777
Jstack指令所输出的信息中包含以下重要信息:
(1)tid:线程实例在JVM进程中的id。
(2)nid:线程实例在操作系统中对应的底层线程的线程id。
(3)prio:线程实例在JVM进程中的优先级。
(4)os_prio:线程实例在操作系统中对应的底层线程的优先级。
(5)线程状态:如runnable、waiting on condition等。
1.5.2 线程的sleep操作
sleep的作用是让目前正在执行的线程休眠,让CPU去执行其他的任务。从线程状态来说,就是从执行状态变成限时阻塞状态。Sleep()方法定义在Thread类中,是一组静态方法,有两个重载版本
image-20211105225324906
1.5.3 线程的interrupt操作
Java语言提供了stop()方法终止正在运行的线程,但是Java将Thread的stop()方法设置为过时,不建议大家使用。为什么呢?因为使用stop()方法是很危险的,就像突然关闭计算机电源,而不是按正常程序关机。在程序中,我们是不能随便中断一个线程的,我们无法知道这个线程正运行在什么状态,它可能持有某把锁,强行中断线程可能导致锁不能释放的问题;或者线程可能在操作数据库,强行中断线程可能导致数据不一致的问题。正是由于调用stop()方法来终止线程可能会产生不可预料的结果,因此不推荐调用stop()方法。
当我们调用线程的interrupt()方法时,它有两个作用:
(1)如果此线程处于阻塞状态(如调用了Object.wait()方法),就会立马退出阻塞,并抛出InterruptedException异常,线程就可以通过捕获InterruptedException来做一定的处理,然后让线程退出。更确切地说,如果线程被Object.wait()、Thread.join()和Thread.sleep()三种方法之一阻塞,此时调用该线程的interrupt()方法,该线程将抛出一个InterruptedException中断异常(该线程必须事先预备好处理此异常),从而提早终结被阻塞状态。
(2)如果此线程正处于运行之中,线程就不受任何影响,继续运行,仅仅是线程的中断标记被设置为true。所以,程序可以在适当的位置通过调用isInterrupted()方法来查看自己是否被中断,并执行退出操作。
1.5.4 线程的join操作
线程的合并是一个比较难以说清楚的概念,什么是线程的合并呢?举一个例子,假设有两个线程A和B。现在线程A在执行过程中对另一个线程B的执行有依赖,具体的依赖为:线程A需要将线程B的执行流程合并到自己的执行流程中(至少表面如此),这就是线程合并,被动方线程B可以叫作被合并线程。
调用join()方法的要点:
(1)join()方法是实例方法,需要使用被合并线程的句柄(或者指针、变量)去调用,如threadb.join()。执行threadb.join()这行代码的当前线程为合并线程(甲方),进入TIMED_WAITING等待状态,让出CPU。
(2)如果设置了被合并线程的执行时间millis(或者millis+nanos),并不能保证当前线程一定会在millis时间后变为RUNNABLE。
(3)如果主动方合并线程在等待时被中断,就会抛出InterruptedException受检异常。
调用join()方法的语句可以理解为合并点,合并的本质是:线程A需要在合并点等待,一直等到线程B执行完成,或者等待超时。
调用join()方法的优势是比较简单,劣势是join()方法没有办法直接取得乙方线程的执行结果。
image-20211105225813939
3.join线程的WAITING状态
线程的WAITING(等待)状态表示线程在等待被唤醒。处于WAITING状态的线程不会被分配CPU时间片。执行以下两个操作,当前线程将处于WAITING状态:
(1)执行没有时限(timeout)参数的thread.join()调用:在线程合并场景中,若线程A调用B.join()去合入B线程,则在B执行期间线程A处于WAITING状态,一直等线程B执行完成。
(2)执行没有时限(timeout)参数的object.wait()调用:指一个拥有object对象锁的线程,进入相应的代码临界区后,调用相应的object的wait()方法去等待其“对象锁”(ObjectMonitor)上的信号,若“对象锁”上没有信号,则当前线程处于WAITING状态,
4.join线程的TIMED_WAITING状态
线程的TIMED_WAITING状态表示在等待唤醒。处于TIMED_WAITING状态的线程不会被分配CPU时间片,它们要等待被唤醒,或者直到等待的时限到期。
在线程合入场景中,若线程A在调用B.join()操作时加入了时限参数,则在B执行期间线程A处于TIMED_WAITING状态。若B在等待时限内没有返回,则线程A结束等待TIMED_WAITING状态,恢复成RUNNABLE状态。
1.5.5 线程的yield操作
线程的yield(让步)操作的作用是让目前正在执行的线程放弃当前的执行,让出CPU的执行权限,使得CPU去执行其他的线程。处于让步状态的JVM层面的线程状态仍然是RUNNABLE状态,但是该线程所对应的操作系统层面的线程从状态上来说会从执行状态变成就绪状态。线程在yield时,线程放弃和重占CPU的时间是不确定的,可能是刚刚放弃CPU,马上又获得CPU执行权限,重新开始执行。
总结起来,Thread.yeid()方法有以下特点:
(1)yield仅能使一个线程从运行状态转到就绪状态,而不是阻塞状态。
(2)yield不能保证使得当前正在运行的线程迅速转换到就绪状态。
(3)即使完成了迅速切换,系统通过线程调度机制从所有就绪线程中挑选下一个执行线程时,就绪的线程有可能被选中,也有可能不被选中,其调度的过程受到其他因素(如优先级)的影响。
1.5.6 线程的daemon操作
Java中的线程分为两类:守护线程与用户线程。守护线程也称为后台线程,专门指在程序进程运行过程中,在后台提供某种通用服务的线程。比如,每启动一个JVM进程,都会在后台运行一系列的GC(垃圾回收)线程,这些GC线程就是守护线程,提供幕后的垃圾回收服务。
1.守护线程的基本操作
在Thread类中,有一个实例属性和两个实例方法,专门用于进行守护线程相关的操作。(1)实例属性daemon:保存一条Thread线程实例的守护状态,默认为false,表示线程默认为用户线程。
private boolean daemon = false;
(2)实例方法setDaemon(…):此方法将线程标记为守护线程或者用户线程。setDaemon(true)将线程设置为守护线程,setDaemon(false)将线程设置为用户线程。
(3)实例方法isDaemon():获取线程的守护状态,用于判断该线程是不是守护线程。
3.守护线程与用户线程的关系
从是否为守护线程的角度,对Java线程进行分类,分为用户线程和守护线程。守护线程和用户线程的本质区别是:二者与JVM虚拟机进程终止的方向不同。用户线程和JVM进程是主动关系,如果用户线程全部终止,JVM虚拟机进程也随之终止;守护线程和JVM进程是被动关系,如果JVM进程终止,所有的守护线程也随之终止
换个角度来理解,守护线程提供服务,是守护者,用户线程享受服务,是被守护者。只有用户线程全部终止了,相当于没有了被守护者,守护线程也就没有工作可做了,也就可以全部终止了。当然,用户线程全部终止,JVM进程也就没有继续的必要了。反过来说,只要有一个用户线程没有终止,JVM进程也不会退出。
1.5.7 线程状态总结
1.NEW状态
通过new Thread(…)已经创建线程,但尚未调用start()启动线程,该线程处于NEW(新建)状态。虽然前面介绍了4种方式创建线程,但是其中的其他三种方式本质上都是通过newThread()创建线程,仅仅是创建了不同的target执行目标实例(如Runnable实例)
2.RUNNABLE状态
Java把Ready(就绪)和Running(执行)两种状态合并为一种状态:RUNNABLE(可执行)状态(或者可运行状态)。调用了线程的start()实例方法后,线程就处于就绪状态。此线程获取到CPU时间片后,开始执行run()方法中的业务代码,线程处于执行状态。
(1)就绪状态
就绪状态仅仅表示线程具备运行资格,如果没有被操作系统的调度程序挑选中,线程就永远处于就绪状态。当前线程进入就绪状态的条件大致包括以下几种:·调用线程的start()方法,此线程就会进入就绪状态。·当前线程的执行时间片用完。·线程睡眠(Sleep)操作结束。·对其他线程合入(Join)操作结束。·等待用户输入结束。·线程争抢到对象锁(Object Monitor)·当前线程调用了yield()方法出让CPU执行权限。
(2)执行状态
线程调度程序从就绪状态的线程中选择一个线程,被选中的线程状态将变成执行状态。这也是线程进入执行状态的唯一方式。
3.BLOCKED状态
处于BLOCKED(阻塞)状态的线程并不会占用CPU资源,以下情况会让线程进入阻塞状态:
(1)线程等待获取锁等待获取一个锁,而该锁被其他线程持有,则该线程进入阻塞状态。当其他线程释放了该锁,并且线程调度器允许该线程持有该锁时,该线程退出阻塞状态。
(2)IO阻塞线程发起了一个阻塞式IO操作后,如果不具备IO操作的条件,线程就会进入阻塞状态。IO包括磁盘IO、网络IO等。IO阻塞的一个简单例子:线程等待用户输入内容后继续执行。
4.WAITING状态
处于WAITING(无限期等待)状态的线程不会被分配CPU时间片,需要被其他线程显式地唤醒,才会进入就绪状态。线程调用以下3种方法会让自己进入无限等待状态:
·Object.wait()方法,对应的唤醒方式为:Object.notify()/Object.notifyAll()
·Thread.join()方法,对应的唤醒方式为:被合入的线程执行完毕。
·LockSupport.park()方法,对应的唤醒方式为:LockSupport.unpark(Thread)。
5.TIMED_WAITING状态
处于TIMED_WAITING(限时等待)状态的线程不会被分配CPU时间片,如果指定时间之内没有被唤醒,限时等待的线程会被系统自动唤醒,进入就绪状态。以下3种方法会让线程进入限时等待状态:
·Thread.sleep(time)方法,对应的唤醒方式为:sleep睡眠时间结束。
·Object.wait(time)方法,对应的唤醒方式为:调用Object.notify()/Object.notifyAll()主动唤醒,或者限时结束。
·LockSupport.parkNanos(time)/parkUntil(time)方法,对应的唤醒方式为:线程调用配套的LockSupport.unpark(Thread)方法结束,或者线程停止(park)时限结束。
进入BLOCKED状态、WAITING状态、TIMED_WAITING状态的线程都会让出CPU的使用权;另外,等待或者阻塞状态的线程被唤醒后,进入Ready状态,需要重新获取时间片才能接着运行。
6.TERMINATED状态
线程结束任务之后,将会正常进入TERMINATED(死亡)状态;或者说在线程执行过程中发生了异常(而没有被处理),也会导致线程进入死亡状态。
1.6 线程池原理与实战
Java线程的创建非常昂贵,需要JVM和OS(操作系统)配合完成大量的工作:
(1)必须为线程堆栈分配和初始化大量内存块,其中包含至少1MB的栈内存。
(2)需要进行系统调用,以便在OS(操作系统)中创建和注册本地线程。
Java高并发应用频繁创建和销毁线程的操作是非常低效的,而且是不被编程规范所允许的。如何降低Java线程的创建成本?必须使用到线程池。线程池主要解决了以下两个问题:
(1)提升性能:线程池能独立负责线程的创建、维护和分配。在执行大量异步任务时,可以不需要自己创建线程,而是将任务交给线程池去调度。线程池能尽可能使用空闲的线程去执行异步任务,最大限度地对已经创建的线程进行复用,使得性能提升明显。
(2)线程管理:每个Java线程池会保持一些基本的线程统计信息,例如完成的任务数量、空闲时间等,以便对线程进行有效管理,使得能对所接收到的异步任务进行高效调度。
1.6.1 JUC的线程池架构
看下java中线程池是怎么回事:
image-20211107171714149
JUC就是java.util.concurrent工具包的简称,该工具包是从JDK1.5开始加入JDK的,是用于完成高并发、处理多线程的一个工具包。
1.Executor
Executor是Java异步目标任务的“执行者”接口,其目标是执行目标任务。“执行者”Executor提供了execute()接口来执行已提交的Runnable执行目标实例。Executor作为执行者的角色,其目的是提供一种将“任务提交者”与“任务执行者”分离开来的机制。它只包含一个函数式方法:
void execute(Runnable command);
2.ExecutorService
ExecutorService继承于Executor。它是Java异步目标任务的“执行者服务接”口,对外提供异步任务的接收服务。ExecutorService提供了“接收异步任务并转交给执行者”的方法,如submit系列方法、invoke系列方法等
3.AbstractExecutorService
AbstractExecutorService是一个抽象类,它实现了ExecutorService接口。AbstractExecutorService存在的目的是为ExecutorService中的接口提供默认实现。
4.ThreadPoolExecutor
ThreadPoolExecutor就是大名鼎鼎的“线程池”实现类,它继承于AbstractExecutorService抽象类。ThreadPoolExecutor是JUC线程池的核心实现类。线程的创建和终止需要很大的开销,线程池中预先提供了指定数量的可重用线程,所以使用线程池会节省系统资源,并且每个线程池都维护了一些基础的数据统计,方便线程的管理和监控。
5.ScheduledExecutorService
ScheduledExecutorService是一个接口,它继承于ExecutorService。它是一个可以完成“延时”和“周期性”任务的调度线程池接口,其功能和Timer/TimerTask类似。
6.ScheduledThreadPoolExecutor
ScheduledThreadPoolExecutor继承于ThreadPoolExecutor,它提供了ScheduledExecutorService线程池接口中“延时执行”和“周期执行”等抽象调度方法的具体实现。ScheduledThreadPoolExecutor类似于Timer,但是在高并发程序中,ScheduledThreadPoolExecutor的性能要优于Timer
7.Executors
Executors是一个静态工厂类,它通过静态工厂方法返回ExecutorService、ScheduledExecutorService等线程池示例对象,这些静态工厂方法可以理解为一些快捷的创建线程池的方法。
1.6.2 Executors的4种快捷创建线程池的方法
image-20211107192244686
1.newSingleThreadExecutor创建“单线程化线程池”
该方法用于创建一个“单线程化线程池”,也就是只有一个线程的线程池,所创建的线程池用唯一的工作线程来执行任务,使用此方法创建的线程池能保证所有任务按照指定顺序(如FIFO)执行。
(1)单线程化的线程池中的任务是按照提交的次序顺序执行的。
(2)池中的唯一线程的存活时间是无限的。
(3)当池中的唯一线程正繁忙时,新提交的任务实例会进入内部的阻塞队列中,并且其阻塞队列是无界的。总体来说,单线程化的线程池所适用的场景是:任务按照提交次序,一个任务一个任务地逐个执行的场景。
2.newFixedThreadPool创建“固定数量的线程池”
该方法用于创建一个“固定数量的线程池”,其唯一的参数用于设置池中线程的“固定数量”。调用Executors.newFixedThreadPool(int threads)快捷工厂方法创建“固定数量的线程池”的测试用例
“固定数量的线程池”的特点大致如下:
(1)如果线程数没有达到“固定数量”,每次提交一个任务线程池内就创建一个新线程,直到线程达到线程池固定的数量。
(2)线程池的大小一旦达到“固定数量”就会保持不变,如果某个线程因为执行异常而结束,那么线程池会补充一个新线程。
(3)在接收异步任务的执行目标实例时,如果池中的所有线程均在繁忙状态,新任务会进入阻塞队列中(无界的阻塞队列)。
“固定数量的线程池”的适用场景:需要任务长期执行的场景。“固定数量的线程池”的线程数能够比较稳定地保证一个数,能够避免频繁回收线程和创建线程,故适用于处理CPU密集型的任务,在CPU被工作线程长时间占用的情况下,能确保尽可能少地分配线程。
“固定数量的线程池”的弊端:内部使用无界队列来存放排队任务,当大量任务超过线程池最大容量需要处理时,队列无限增大,使服务器资源迅速耗尽。
3.newCachedThreadPool创建“可缓存线程池”
该方法用于创建一个“可缓存线程池”,如果线程池内的某些线程无事可干成为空闲线程,“可缓存线程池”可灵活回收这些空闲线程。
可缓存线程池”的特点大致如下:
(1)在接收新的异步任务target执行目标实例时,如果池内所有线程繁忙,此线程池就会添加新线程来处理任务。
(2)此线程池不会对线程池大小进行限制,线程池大小完全依赖于操作系统(或者说JVM)能够创建的最大线程大小。
(3)如果部分线程空闲,也就是存量线程的数量超过了处理任务数量,就会回收空闲(60秒不执行任务)线程。
可缓存线程池”的适用场景:需要快速处理突发性强、耗时较短的任务场景,如Netty的NIO处理场景、REST API接口的瞬时削峰场景。“可缓存线程池”的线程数量不固定,只要有空闲线程就会被回收;接收到的新异步任务执行目标,查看是否有线程处于空闲状态,如果没有就直接创建新的线程。
“可缓存线程池”的弊端:线程池没有最大线程数量限制,如果大量的异步任务执行目标实例同时提交,可能会因创建线程过多而导致资源耗尽。
4.newScheduledThreadPool创建“可调度线程池”
该方法用于创建一个“可调度线程池”,即一个提供“延时”和“周期性”任务调度功能的ScheduledExecutorService类型的线程池。
“可调度线程池”的适用场景:周期性地执行任务的场景。Spring Boot中的任务调度器,底层借助了JUC的ScheduleExecutorService“可调度线程池”实现,并且可以通过@Configuration配置类型的Bean
1.6.3 线程池的标准创建方式
大部分企业的开发规范都会禁止使用快捷线程池(具体原因稍后介绍),要求通过标准构造器ThreadPoolExecutor去构造工作线程池。Executors工厂类中创建线程池的快捷工厂方法实际上是调用ThreadPoolExecutor(定时任务使用ScheduledThreadPoolExecutor)线程池的构造方法完成的。ThreadPoolExecutor构造方法有多个重载版本,其中一个比较重要的构造器如下:
image-20211107194118845
1.核心和最大线程数量
参数corePoolSize用于设置核心(Core)线程池数量,参数maximumPoolSize用于设置最大线程数量。线程池执行器将会根据corePoolSize和maximumPoolSize自动维护线程池中的工作线程,大致规则为:
(1)当在线程池接收到新任务,并且当前工作线程数少于corePoolSize时,即使其他工作线程处于空闲状态,也会创建一个新线程来处理该请求,直到线程数达到corePoolSize。
(2)如果当前工作线程数多于corePoolSize数量,但小于maximumPoolSize数量,那么仅当任务排队队列已满时才会创建新线程。通过设置corePoolSize和maximumPoolSize相同,可以创建一个固定大小的线程池。
(3)当maximumPoolSize被设置为无界值(如Integer.MAX_VALUE)时,线程池可以接收任意数量的并发任务。
(4)corePoolSize和maximumPoolSize不仅能在线程池构造时设置,也可以调用setCorePoolSize()和setMaximumPoolSize()两个方法进行动态更改。
2.BlockingQueue
BlockingQueue(阻塞队列)的实例用于暂时接收到的异步任务,如果线程池的核心线程都在忙,那么所接收到的目标任务缓存在阻塞队列中。
3.keepAliveTime
线程构造器的keepAliveTime(空闲线程存活时间)参数用于设置池内线程最大Idle(空闲)时长(或者说保活时长),如果超过这个时间,默认情况下Idle、非Core线程会被回收。
默认情况下,Idle超时策略仅适用于存在超过corePoolSize线程的情况。但若调用了allowCoreThreadTimeOut(boolean)方法,并且传入了参数true,则keepAliveTime参数所设置的Idle超时策略也将被应用于核心线程。
1.6.4 向线程池提交任务的两种方式
方式一:调用execute()方法
方式二:调用submit()方法
以上的submit()和execute()两类方法的区别在哪里呢?大致有以下三点:
(1)二者所接收的参数不一样Execute()方法只能接收Runnable类型的参数,而submit()方法可以接收Callable、Runnable两种类型的参数。Callable类型的任务是可以返回执行结果的,而Runnable类型的任务不可以返回执行结果。
(2)submit()提交任务后会有返回值,而execute()没有execute()方法主要用于启动任务的执行,而任务的执行结果和可能的异常调用者并不关心。submit()方法也用于启动任务的执行,但是启动之后会返回Future对象,代表一个异步执行实例,可以通过该异步执行实例去获取结果。
(3)submit()方便Exception处理
execute()方法在启动任务执行后,任务执行过程中可能发生的异常调用者并不关心。而通过submit()方法返回的Future对象(异步执行实例),可以进行异步执行过程中的异常捕获。
1.通过submit()返回的Future对象获取结果
submit()方法自身并不会传递结果,而是返回一个Future异步执行实例,处理过程的结果被包装到Future实例中,调用者可以通过Future.get()方法获取异步执行的结果。通过submit()返回的Future对象获取异步执行结果
2.通过submit()返回的Future对象捕获异常
submit()方法自身并不会传递异常,处理过程中的异常都被包装到Future实例中,调用者在调用Future.get()方法获取执行结果时,可以捕获异步执行过程中抛出的受检异常和运行时异常,并进行对应的业务处理。
1.6.5 线程池的任务调度流程
线程池的任务调度流程(包含接收新任务和执行下一个任务)大致如下:
(1)如果当前工作线程数量小于核心线程数量,执行器总是优先创建一个任务线程,而不是从线程队列中获取一个空闲线程。
(2)如果线程池中总的任务数量大于核心线程池数量,新接收的任务将被加入阻塞队列中,一直到阻塞队列已满。在核心线程池数量已经用完、阻塞队列没有满的场景下,线程池不会为新任务创建一个新线程。
(3)当完成一个任务的执行时,执行器总是优先从阻塞队列中获取下一个任务,并开始执行,一直到阻塞队列为空,其中所有的缓存任务被取光。
(4)在核心线程池数量已经用完、阻塞队列也已经满了的场景下,如果线程池接收到新的任务,将会为新任务创建一个线程(非核心线程),并且立即开始执行新任务。
(5)在核心线程都用完、阻塞队列已满的情况下,一直会创建新线程去执行新任务,直到池内的线程总数超出maximumPoolSize。如果线程池的线程总数超过maximumPoolSize,线程池就会拒绝接收任务,当新任务过来时,会为新任务执行拒绝策略。
总体的线程池的任务调度流程如图1-16所示。
image-20211107195304574
以上示例的目的是传递两个知识点:
(1)核心和最大线程数量、BlockingQueue队列等参数如果配置得不合理,可能会造成异步任务得不到预期的并发执行,造成严重的排队等待现象。
(2)线程池的调度器创建线程的一条重要的规则是:在corePoolSize已满之后,还需要等阻塞队列已满,才会去创建新的线程。
1.6.6 ThreadFactory(线程工厂)
ThreadFactory是Java线程工厂接口,这是一个非常简单的接口,具体如下:
public interface ThreadFactory {
Thread newThread(Runnable r);
}
这里提到了两个工厂类,比较容易混淆,故做出说明。Executors为线程池工厂类,用于快捷创建线程池(ThreadPool);ThreadFactory为线程工厂类,用于创建线程(Thread)。
基于自定义的ThreadFactory实例创建线程池,首先需要实现一个ThreadFactory类,实现其唯一的抽象方法newThread(Runnable)。
1.6.7 任务阻塞队列
Java中的阻塞队列(BlockingQueue)与普通队列相比有一个重要的特点:在阻塞队列为空时会阻塞当前线程的元素获取操作。具体来说,在一个线程从一个空的阻塞队列中获取元素时线程会被阻塞,直到阻塞队列中有了元素;当队列中有元素后,被阻塞的线程会自动被唤醒(唤醒过程不需要用户程序干预)。
Java线程池使用BlockingQueue实例暂时接收到的异步任务,BlockingQueue是JUC包的一个超级接口,比较常用的实现类有:
(1)ArrayBlockingQueue:是一个数组实现的有界阻塞队列(有界队列),队列中的元素按FIFO排序。ArrayBlockingQueue在创建时必须设置大小,接收的任务超出corePoolSize数量时,任务被缓存到该阻塞队列中,任务缓存的数量只能为创建时设置的大小,若该阻塞队列已满,则会为新的任务创建线程,直到线程池中的线程总数大于maximumPoolSize。
(2)LinkedBlockingQueue:是一个基于链表实现的阻塞队列,按FIFO排序任务,可以设置容量(有界队列),不设置容量则默认使用Integer.Max_VALUE作为容量(无界队列)。该队列的吞吐量高于ArrayBlockingQueue。
如果不设置LinkedBlockingQueue的容量(无界队列),当接收的任务数量超出corePoolSize时,则新任务可以被无限制地缓存到该阻塞队列中,直到资源耗尽。有两个快捷创建线程池的工厂方法Executors.newSingleThreadExecutor和Executors.newFixedThreadPool使用了这个队列,并且都没有设置容量(无界队列)。
(3)PriorityBlockingQueue:是具有优先级的无界队列。
(4)DelayQueue:这是一个无界阻塞延迟队列,底层基于PriorityBlockingQueue实现,队列中每个元素都有过期时间,当从队列获取元素(元素出队)时,只有已经过期的元素才会出队,队列头部的元素是过期最快的元素。快捷工厂方法Executors.newScheduledThreadPool所创建的线程池使用此队列。
(5)SynchronousQueue:(同步队列)是一个不存储元素的阻塞队列,每个插入操作必须等到另一个线程的调用移除操作,否则插入操作一直处于阻塞状态,其吞吐量通常高于LinkedBlockingQueue。快捷工厂方法Executors.newCachedThreadPool所创建的线程池使用此队列。与前面的队列相比,这个队列比较特殊,它不会保存提交的任务,而是直接新建一个线程来执行新来的任务。
1.6.8 调度器的钩子方法
ThreadPoolExecutor线程池调度器为每个任务执行前后都提供了钩子方法。ThreadPoolExecutor类提供了三个钩子方法(空方法),这三个钩子方法一般用作被子类重写,具体如下:
protected void terminated() { }
protected void beforeExecute(Thread t, Runnable r) { }
protected void afterExecute(Runnable r, Throwable t) { }
(1)beforeExecute:异步任务执行之前的钩子方法
(2)afterExecute:异步任务执行之后的钩子方法
(3)terminated:线程池终止时的钩子方法
1.6.9 线程池的拒绝策略
在线程池的任务缓存队列为有界队列(有容量限制的队列)的时候,如果队列满了,提交任务到线程池的时候就会被拒绝。总体来说,任务被拒绝有两种情况:
(1)线程池已经被关闭。
(2)工作队列已满且maximumPoolSize已满。
无论以上哪种情况任务被拒绝,线程池都会调用RejectedExecutionHandler实例的rejectedExecution方法。RejectedExecutionHandler是拒绝策略的接口,JUC为该接口提供了以下几种实现:
(1)AbortPolicy
使用该策略时,如果线程池队列满了,新任务就会被拒绝,并且抛出RejectedExecutionException异常。该策略是线程池默认的拒绝策略。
(2)DiscardPolicy
该策略是AbortPolicy的Silent(安静)版本,如果线程池队列满了,新任务就会直接被丢掉,并且不会有任何异常抛出。
(3)DiscardOldestPolicy
抛弃最老任务策略,也就是说如果队列满了,就会将最早进入队列的任务抛弃,从队列中腾出空间,再尝试加入队列。因为队列是队尾进队头出,队头元素是最老的,所以每次都是移除队头元素后再尝试入队。
(4)CallerRunsPolicy
调用者执行策略。在新任务被添加到线程池时,如果添加失败,那么提交任务线程会自己去执行该任务,不会使用线程池中的线程去执行新任务。
在以上4种内置策略中,线程池默认的拒绝策略为AbortPolicy,如果提交的任务被拒绝,线程池就会抛出RejectedExecutionException异常,该异常是非受检异常(运行时异常),很容易忘记捕获。如果关心任务被拒绝的事件,需要在提交任务时捕获RejectedExecutionException异常。
(5)自定义策略
如果以上拒绝策略都不符合需求,那么可自定义一个拒绝策略,实现RejectedExecutionHandler接口的rejectedExecution方法即可。
1.6.10 线程池的优雅关闭
线程池的5种状态具体如下:
(1)RUNNING:线程池创建之后的初始状态,这种状态下可以执行任务。
(2)SHUTDOWN:该状态下线程池不再接受新任务,但是会将工作队列中的任务执行完毕。
(3)STOP:该状态下线程池不再接受新任务,也不会处理工作队列中的剩余任务,并且将会中断所有工作线程。
(4)TIDYING:该状态下所有任务都已终止或者处理完成,将会执行terminated()钩子方法。
(5)TERMINATED:执行完terminated()钩子方法之后的状态。
优雅地关闭线程池主要涉及的方法有3个:
(1)shutdown:是JUC提供的一个有序关闭线程池的方法,此方法会等待当前工作队列中的剩余任务全部执行完成之后,才会执行关闭,但是此方法被调用之后线程池的状态转为SHUTDOWN,线程池不会再接收新的任务。此时如果还继续往线程池提交任务,将会使用线程池拒绝策略响应,默认的拒绝策略将会使用ThreadPoolExecutor.AbortPolicy,接收新任务时会抛出RejectedExecutionException异常。
image-20211107204857763
(2)shutdownNow:是JUC提供的一个立即关闭线程池的方法,此方法会打断正在执行的工作线程,并且会清空当前工作队列中的剩余任务,返回的是尚未执行的任务。
(3)awaitTermination:等待线程池完成关闭。在调用线程池的shutdown()与shutdownNow()方法时,当前线程会立即返回,不会一直等待直到线程池完成关闭。如果需要等到线程池关闭完成,可以调用awaitTermination()方法。
1.优雅地关闭线程池
大家可以结合shutdown()、shutdownNow()、awaitTermination()三个方法优雅地关闭一个线程池,大致分为以下几步:
(1)执行shutdown()方法,拒绝新任务的提交,并等待所有任务有序地执行完毕。
(2)执行awaitTermination(long timeout,TimeUnit unit)方法,指定超时时间,判断是否已经关闭所有任务,线程池关闭完成。
(3)如果awaitTermination()方法返回false,或者被中断,就调用shutDownNow()方法立即关闭线程池所有任务。
(4)补充执行awaitTermination(long timeout,TimeUnitunit)方法,判断线程池是否关闭完成。如果超时,就可以进入循环关闭,循环一定的次数(如1000次),不断关闭线程池,直到其关闭或者循环结束。
1.6.11 Executors快捷创建线程池的潜在问题
在很多公司(如阿里、华为等)的编程规范中,非常明确地禁止使用Executors快捷创建线程池,为什么呢?这里从源码讲起,介绍使用Executors工厂方法快捷创建线程池将会面临的潜在问题。
1.使用Executors创建“固定数量的线程池”的潜在问题
使用Executors创建“固定数量的线程池”的潜在问题主要存在于其workQueue上,其值为LinkedBlockingQueue(无界阻塞队列)。如果任务提交速度持续大于任务处理速度,就会造成队列中大量的任务等待。如果队列很大,很有可能导致JVM出现OOM(Out Of Memory)异常,即内存资源耗尽。
2.使用Executors创建“单线程化线程池”的潜在问题
使用Executors创建的“单线程化线程池”与“固定大小的线程池”一样,其潜在问题仍然存在于其workQueue属性上,该属性的值为LinkedBlockingQueue(无界阻塞队列)。如果任务提交速度持续大于任务处理速度,就会造成队列大量阻塞。如果队列很大,很有可能导致JVM的OOM异常,甚至造成内存资源耗尽。
3.使用Executors创建“可缓存线程池”的潜在问题
当“可缓存线程池”有新任务到来时,新任务会被插入SynchronousQueue实例中,由于SynchronousQueue是同步队列,因此会在池中寻找可用线程来执行,若有可用线程则执行,若没有可用线程,则线程池会创建一个线程来执行该任务。
使用Executors创建的“可缓存线程池”的潜在问题存在于其最大线程数量不设限上。由于其maximumPoolSize的值为Integer.MAX_VALUE(非常大),可以认为可以无限创建线程,如果任务提交较多,就会造成大量的线程被启动,很有可能造成OOM异常,甚至导致CPU线程资源耗尽。
4.使用Executors创建“可调度线程池”的潜在问题
maximumPoolSize的值为Integer.MAX_VALUE(非常大)同可缓存线程池
1.7 确定线程池的线程数
1.7.1 按照任务类型对线程池进行分类
使用标准构造器ThreadPoolExecutor创建线程池时,会涉及线程数的配置,而线程数的配置与异步任务类型是分不开的。这里将线程池的异步任务大致分为以下三类:
(1)IO密集型任务此类任务主要是执行IO操作。由于执行IO操作的时间较长,导致CPU的利用率不高,这类任务CPU常处于空闲状态。Netty的IO读写操作为此类任务的典型例子。
(2)CPU密集型任务此类任务主要是执行计算任务。由于响应时间很快,CPU一直在运行,这种任务CPU的利用率很高。
(3)混合型任务此类任务既要执行逻辑计算,又要进行IO操作(如RPC调用、数据库访问)。相对来说,由于执行IO操作的耗时较长(一次网络往返往往在数百毫秒级别),这类任务的CPU利用率也不是太高。Web服务器的HTTP请求处理操作为此类任务的典型例子。
1.7.2 为IO密集型任务确定线程数
由于IO密集型任务的CPU使用率较低,导致线程空余时间很多,因此通常需要开CPU核心数两倍的线程。当IO线程空闲时,可以启用其他线程继续使用CPU,以提高CPU的使用率。
1.7.3 为CPU密集型任务确定线程数
CPU密集型任务也叫计算密集型任务,其特点是要进行大量计算而需要消耗CPU资源,比如计算圆周率、对视频进行高清解码等。CPU密集型任务虽然也可以并行完成,但是并行的任务越多,花在任务切换的时间就越多,CPU执行任务的效率就越低,所以要最高效地利用CPU,CPU密集型任务并行执行的数量应当等于CPU的核心数。
比如4个核心的CPU,通过4个线程并行地执行4个CPU密集型任务,此时的效率是最高的。但是如果线程数远远超出CPU核心数量,就需要频繁地切换线程,线程上下文切换时需要消耗时间,反而会使得任务效率下降。因此,对于CPU密集型的任务来说,线程数等于CPU数就行。
1.7.4 为混合型任务确定线程数
混合型任务既要执行逻辑计算,又要进行大量非CPU耗时操作(如RPC调用、数据库访问、网络通信等),所以混合型任务CPU的利用率不是太高,非CPU耗时往往是CPU耗时的数倍。比如在Web应用中处理HTTP请求时,一次请求处理会包括DB操作、RPC操作、缓存操作等多种耗时操作。一般来说,一次Web请求的CPU计算耗时往往较少,大致在100~500毫秒,而其他耗时操作会占用500~1000毫秒,甚至更多的时间。在为混合型任务创建线程池时,如何确定线程数呢?业界有一个比较成熟的估算公式,具体如下:
image-20211107213749516
为什么Redis使用单线程如此之快,原因在于:Redis基本都是内存操作,在这种情况下单线程可以高效地利用CPU,多线程会带来线程上下文切换的开销,单线程就没有这种开销。有关Redis的知识,可参考笔者的另一本书《Java高并发核心编程 卷1:NIO、Netty、Redis、ZooKeeper》。由于Redis基本都是内存操作,在这种情况下单线程可以高效地利用CPU,多线程反而不是太适用。多线程适用的场景一般是:存在相当比例非CPU耗时操作,如IO、网络操作,需要尽量提高并行化比率以提升CPU的利用率。
1.8 ThreadLocal原理与实战
在Java的多线程并发执行过程中,为了保证多个线程对变量的安全访问,可以将变量放到ThreadLocal类型的对象中,使变量在每个线程中都有独立值,不会出现一个线程读取变量时被另一个线程修改的现象。ThreadLocal类通常被翻译为“线程本地变量”类或者“线程局部变量”类。
1.8.1 ThreadLocal的基本使用
ThreadLocal的英文字面意思为“本地线程”,实际上ThreadLocal代表的是线程的本地变量,可能将其命名为ThreadLocalVariable更加容易让人理解。
ThreadLocal如何做到为每个线程存有一份独立的本地值呢?一个ThreadLocal实例可以形象地理解为一个Map(早期版本的ThreadLocal是这样设计的)。当工作线程Thread实例向本地变量保持某个值时,会以“Key-Value对”(即键-值对)的形式保存在ThreadLocal内部的Map中,其中Key为线程Thread实例,Value为待保存的值。当工作线程Thread实例从ThreadLocal本地变量取值时,会以Thread实例为Key,获取其绑定的Value。
java程序可以调用ThreadLocal的成员方法进行本地值的操作,具体的成员方法如表1-2所示。
image-20211107215406823
image-20211107215416255
ThreadLocal.withInitial(…)静态工厂方法,防止没有set的时候直接get,这个时候会报空指针异常,所以要new一个变量。
image-20211107215619640
1.8.2 ThreadLocal的使用场景
ThreadLocal的使用场景大致可以分为以下两类:
(1)线程隔离ThreadLocal的主要价值在于线程隔离,ThreadLocal中的数据只属于当前线程,其本地值对别的线程是不可见的,在多线程环境下,可以防止自己的变量被其他线程篡改。另外,由于各个线程之间的数据相互隔离,避免了同步加锁带来的性能损失,大大提升了并发性的性能。
ThreadLocal在线程隔离的常用案例为:可以为每个线程绑定一个用户会话信息、数据库连接、HTTP请求等,这样一个线程所有调用到的处理函数都可以非常方便地访问这些资源。
常见的ThreadLocal使用场景为数据库连接独享、Session数据管理等。
在“线程隔离”场景中,使用ThreadLocal的典型案例为:可以为每个线程绑定一个数据库连接,使得这个数据库连接为线程所独享,从而避免数据库连接被混用而导致操作异常问题。
(2)跨函数传递数据
通常用于同一个线程内,跨类、跨方法传递数据时,如果不用ThreadLocal,那么相互之间的数据传递势必要靠返回值和参数,这样无形之中增加了这些类或者方法之间的耦合度。
由于ThreadLocal的特性,同一线程在某些地方进行设置,在随后的任意地方都可以获取到。线程执行过程中所执行到的函数都能读写ThreadLocal变量的线程本地值,从而可以方便地实现跨函数的数据传递。使用ThreadLocal保存函数之间需要传递的数据,在需要的地方直接获取,也能避免通过参数传递数据带来的高耦合。
1.8.5 ThreadLocal内部结构演进
在早期的JDK版本中,ThreadLocal的内部结构是一个Map,其中每一个线程实例作为Key,线程在“线程本地变量”中绑定的值为Value(本地值)。早期版本中的Map结构,其拥有者为ThreadLocal,每一个ThreadLocal实例拥有一个Map实例。
在JDK 8版本中,ThreadLocal的内部结构发生了演进,虽然还是使用了Map结构,但是Map结构的拥有者已经发生了变化,其拥有者为Thread(线程)实例,每一个Thread实例拥有一个Map实例。另外,Map结构的Key值也发生了变化:新的Key为ThreadLocal实例。
从代码的层面来说,新版本的ThreadLocalMap还是由ThreadLocal类维护的,由ThreadLocal负责ThreadLocalMap实例的获取和创建,并从中设置本地值、获取本地值。所以ThreadLocalMap还寄存于ThreadLocal内部,并没有被迁移到Thread内部。
1.8.6 ThreadLocal源码分析
ThreadLocal源码提供的方法不多,主要有:set(T value)方法、get()方法、remove()方法和initialValue()方法。
1.set(T value)方法
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
(1)获得当前线程,然后获得当前线程的ThreadLocalMap成员,暂存于map变量。
(2)如果map不为空,就将Value设置到map中,当前的ThreadLocal作为Key。
(3)如果map为空,为该线程创建map,然后设置第一个“Key-Value对”,Key为当前的ThreadLocal实例,Value为set()方法的参数value值。
2.get()方法
代码如下:
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings(“unchecked”)
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
private T setInitialValue() {
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}
通过以上源码可以看出T get()方法的执行流程,大致如下:
(1)先尝试获得当前线程,然后获得当前线程的ThreadLocalMap成员,暂存于map变量。
(2)如果获得的map不为空,那么以当前ThreadLocal实例为Key尝试获得map中的Entry(条目)。
(3)如果Entry不为空,就返回Entry中的Value。
(4)如果Entry为空,就通过调用initialValue初始化钩子函数获取ThreadLocal初始值,并设置在map中。如果map不存在,还会给当前线程创建新ThreadLocalMap成员,并绑定第一个“Key-Value对”。
3.remove()方法
image-20211107221035705
4.initialValue()方法
提供了ThreadLocal.withInitial(…)静态工厂方法,方便大家在定义ThreadLocal实例时设置初始值回调函数。使用工厂方法构造ThreadLocal实例的代码如下:
image-20211107221129141
1.8.7 ThreadLocalMap源码分析
已经是晚上12点了,我不想看了,感兴趣的自己去看下。
什么是内存泄漏?不再用到的内存没有及时释放(归还给系统),就叫作内存泄漏。对于持续运行的服务进程必须及时释放内存,否则内存占用率越来越高,轻则影响系统性能,重则导致进程崩溃甚至系统崩溃
由于ThreadLocalMap中Entry的Key使用了弱引用,在下次GC发生时,就可以使那些没有被其他强引用指向、仅被Entry的Key所指向的ThreadLocal实例能被顺利回收。并且,在Entry的Key引用被回收之后,其Entry的Key值变为null。后续当ThreadLocal的get()、set()或remove()被调用时,ThreadLocalMap的内部代码会清除这些Key为null的Entry,从而完成相应的内存释放。
总结一下,使用ThreadLocal会发生内存泄漏的前提条件如下:
(1)线程长时间运行而没有被销毁。线程池中的Thread实例很容易满足此条件。
(2)ThreadLocal引用被设置为null,且后续在同一Thread实例执行期间,没有发生对其他ThreadLocal实例的get()、set()或remove()操作。只要存在一个针对任何ThreadLocal实例的get()、set()或remove()操作,就会触发Thread实例拥有的ThreadLocalMap的Key为null的Entry清理工作,释放掉ThreadLocal弱引用为null的Entry。
编程规范推荐使用static final修饰ThreadLocal对象
编程规范有云:ThreadLocal实例作为ThreadLocalMap的Key,针对一个线程内的所有操作是共享的,所以建议设置static修饰符,以便被所有的对象共享。由于静态变量会在类第一次被使用时装载,只会分配一次存储空间,此类的所有实例都会共享这个存储空间,所以使用static修饰ThreadLocal就会节约内存空间。另外,为了确保ThreadLocal实例的唯一性,除了使用static修饰之外,还会使用final进行加强修饰,以防止其在使用过程中发生动态变更。
在使用完static、final修饰的ThreadLocal实例之后,必须调用remove()来进行显式的释放操作。
1.8.8 ThreadLocal综合使用案例
由于ThreadLocal使用不当会导致严重的内存泄漏问题,所以为了更好地避免内存泄漏问题的发生,我们使用ThreadLocal时遵守以下两个原则:
(1)尽量使用private static final修饰ThreadLocal实例。使用private与final修饰符主要是为了尽可能不让他人修改、变更ThreadLocal变量的引用,使用static修饰符主要是为了确保ThreadLocal实例的全局唯一。
(2)ThreadLocal使用完成之后务必调用remove()方法。这是简单、有效地避免ThreadLocal引发内存泄漏问题的方法。
第二章 java内置锁的核心原理
Java内置锁是一个互斥锁,这就意味着最多只有一个线程能够获得该锁。
Java中每个对象都可以用作锁,这些锁称为内置锁。线程进入同步代码块或方法时会自动获得该锁,在退出同步代码块或方法时会释放该锁。获得内置锁的唯一途径就是进入这个锁保护的同步代码块或方法。
2.1线程安全问题
什么是线程安全呢?当多个线程并发访问某个Java对象(Object)时,无论系统如何调度这些线程,也无论这些线程将如何交替操作,这个对象都能表现出一致的、正确的行为,那么对这个对象的操作是线程安全的。如果这个对象表现出不一致的、错误的行为,那么对这个对象的操作不是线程安全的,发生了线程的安全问题。
2.1.1自增运算不是线程安全的
一个自增运算符是一个复合操作,至少包括三个JVM指令:“内存取值”“寄存器增加1”和“存值到内存”。这三个指令在JVM内部是独立进行的,中间完全可能会出现多个线程并发进行。
“内存取值”“寄存器增加1”和“存值到内存”这三个JVM指令本身是不可再分的,它们都具备原子性,是线程安全的,也叫原子操作。但是,两个或者两个以上的原子操作合在一起进行操作就不再具备原子性了。比如先读后写,就有可能在读之后,其实这个变量被修改了,出现读和写数据不一致的情况。
2.1.2临界区资源与临界区代码段
临界区资源表示一种可以被多个线程使用的公共资源或共享数据,但是每一次只能有一个线程使用它。一旦临界区资源被占用,想使用该资源的其他线程则必须等待。
在并发情况下,临界区资源是受保护的对象。临界区代码段(Critical Section)是每个线程中访问临界资源的那段代码,多个线程必须互斥地对临界区资源进行访问。线程进入临界区代码段之前,必须在进入区申请资源,申请成功之后执行临界区代码段,执行完成之后释放资源。临界区代码段的进入和退出
image-20211104200006910
2.2 synchronized关键字
在Java中,线程同步使用最多的方法是使用synchronized关键字。每个Java对象都隐含有一把锁,这里称为Java内置锁(或者对象锁、隐式锁)。使用synchronized(syncObject)调用相当于获取syncObject的内置锁,所以可以使用内置锁对临界区代码段进行排他性保护。
2.2.1 synchronized同步方法
在方法声明中设置synchronized同步关键字,保证其方法的代码执行流程是排他性的。任何时间只允许一个线程进入同步方法(临界区代码段),如果其他线程需要执行同一个方法,那么只能等待和排队。
2.2.2 synchronized同步块
synchroinzed同步块的写法是:
synchronized(syncObject) //同步块不是方法
{
//临界区代码段的代码块
}
在synchronized同步块后边的括号中是一个syncObject对象,代表着进入临界区代码段需要获取syncObject对象的监视锁,或者说将syncObject对象监视锁作为临界区代码段的同步锁。由于每一个Java对象都有一把监视锁,因此任何Java对象都能作为synchronized的同步锁。
synchronized方法和synchronized同步块有什么区别呢?总体来说,synchronized方法是一种粗粒度的并发控制,某一时刻只能有一个线程执行该synchronized方法;而synchronized代码块是一种细粒度的并发控制,处于synchronized块之外的其他代码是可以被多个线程并发访问的。在一个方法中,并不一定所有代码都是临界区代码段,可能只有几行代码会涉及线程同步问题。所以synchronized代码块比synchronized方法更加细粒度地控制了多个线程的同步访问。
synchronized方法和synchronized代码块有什么联系呢?在Java的内部实现上,synchronized方法实际上等同于用一个synchronized代码块,这个代码块包含同步方法中的所有语句,然后在synchronized代码块的括号中传入this关键字,使用this对象锁作为进入临界区的同步锁。
2.2.3 静态同步方法
在Java世界里一切皆对象。Java有两种对象:Object实例对象和Class对象。每个类运行时的类型信息用Class对象表示,它包含与类名称、继承关系、字段、方法有关的信息。JVM将一个类加载入自己的方法区内存时,会为其创建一个Class对象,对于一个类来说其Class对象是唯一的。
Class类没有公共的构造方法,Class对象是在类加载的时候由Java虚拟机调用类加载器中的defineClass方法自动构造的,因此不能显式地声明一个Class对象。
所有的类都是在第一次使用时被动态加载到JVM中的(懒加载),其各个类都是在必需时才加载的。这一点与许多传统语言(如C++)都不同,JVM为动态加载机制配套了一个判定一个类是否已经被加载的检查动作,使得类加载器首先检查这个类的Class对象是否已经被加载。如果尚未加载,类加载器就会根据类的全限定名查找.class文件,验证后加载到JVM的方法区内存,并构造其对应的Class对象。
大家都知道,静态方法属于Class实例而不是单个Object实例,在静态方法内部是不可以访问Object实例的this引用(也叫指针、句柄)的。所以,修饰static方法的synchronized关键字就没有办法获得Object实例的this对象的监视锁。
实际上,使用synchronized关键字修饰static方法时,synchronized的同步锁并不是普通Object对象的监视锁,而是类所对应的Class对象的监视锁。
使用synchronized关键字修饰static方法是非常粗粒度的同步机制。通过synchronized关键字所抢占的同步锁什么时候释放呢?一种场景是synchronized块(代码块或者方法)正确执行完毕,监视锁自动释放;另一种场景是程序出现异常,非正常退出synchronized块,监视锁也会自动释放。所以,使用synchronized块时不必担心监视锁的释放问题。
2.3生产者和消费者问题
生产者-消费者问题(Producer-Consumer Problem)也称有限缓冲问题(Bounded-Buffer Problem),是一个多线程同步问题的经典案例。
生产者-消费者问题描述了两类访问共享缓冲区的线程(所谓的“生产者”和“消费者”)在实际运行时会发生的问题。生产者线程的主要功能是生成一定量的数据放到缓冲区中,然后重复此过程。消费者线程的主要功能是从缓冲区提取(或消耗)数据。
生产者-消费者问题的关键是:(1)保证生产者不会在缓冲区满时加入数据,消费者也不会在缓冲区空时消耗数据。(2)保证在生产者加入过程、消费者消耗过程中,不会产生错误的数据和行为。
2.3.1 生产者-消费者模式
image-20211104202020718
在生产者-消费者模式中,至少有以下关键点:
(1)生产者与生产者之间、消费者与消费者之间,对数据缓冲区的操作是并发进行的。
(2)数据缓冲区是有容量上限的。数据缓冲区满后,生产者不能再加入数据;数据缓冲区空时,消费者不能再取出数据。
(3)数据缓冲区是线程安全的。在并发操作数据缓冲区的过程中,不能出现数据不一致的情况;或者在多个线程并发更改共享数据后,不会造成出现脏数据的情况。
(4)生产者或者消费者线程在空闲时需要尽可能阻塞而不是执行无效的空操作,尽量节约CPU资源。
生产者、消费者的逻辑与动作解耦
image-20211104202133265
2.4 Java对象结构与内置锁
2.4.1 Java对象结构
Java对象(Object实例)结构包括三部分:对象头、对象体和对齐字节,具体如图2-4所示
image-20211104202415145
1.Java对象(Object实例)的三部分
(1)对象头
对象头包括三个字段,第一个字段叫作Mark Word(标记字),用于存储自身运行时的数据,例如GC标志位、哈希码、锁状态等信息。
第二个字段叫作Class Pointer(类对象指针),用于存放方法区Class对象的地址,虚拟机通过这个指针来确定这个对象是哪个类的实例。
第三个字段叫作Array Length(数组长度)。如果对象是一个Java数组,那么此字段必须有,用于记录数组长度的数据;如果对象不是一个Java数组,那么此字段不存在,所以这是一个可选字段。
(2)对象体
对象体包含对象的实例变量(成员变量),用于成员属性值,包括父类的成员属性值。这部分内存按4字节对齐。
(3)对齐字节
对齐字节也叫作填充对齐,其作用是用来保证Java对象所占内存字节数为8的倍数HotSpot VM的内存管理要求对象起始地址必须是8字节的整数倍。对象头本身是8的倍数,当对象的实例变量数据不是8的倍数时,便需要填充数据来保证8字节的对齐。
2.对象结构中核心字段的作用
(1)Mark Word(标记字)字段主要用来表示对象的线程锁状态,另外还可以用来配合GC存放该对象的hashCode。
(2)Class Pointer(类对象指针)字段是一个指向方法区中Class信息的指针,意味着该对象可随时知道自己是哪个Class的实例。
(3)Array Length(数组长度)字段占用32位(在32位JVM中)字节,这是可选的,只有当本对象是一个数组对象时才会有这个部分。
(4)对象体用于保存对象属性值,是对象的主体部分,占用的内存空间大小取决于对象的属性数量和类型。 (5)对齐字节并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。当对象实例数据部分没有对齐(8字节的整数倍)时,就需要通过对齐填充来补全。
3.对象结构中的字段长度
Mark Word、Class Pointer、Array Length等字段的长度都与JVM的位数有关。Mark Word的长度为JVM的一个Word(字)大小,也就是说32位JVM的Mark Word为32位,64位JVM的Mark Word为64位。Class Pointer(类对象指针)字段的长度也为JVM的一个Word(字)大小,即32位JVM的MarkWord为32位,64位JVM的Mark Word为64位。
所以,在32位JVM虚拟机中,Mark Word和Class Pointer这两部分都是32位的;在64位JVM虚拟机中,Mark Word和ClassPointer这两部分都是64位的。
对于对象指针而言,如果JVM中的对象数量过多,使用64位的指针将浪费大量内存,通过简单统计,64位JVM将会比32位JVM多耗费50%的内存。为了节约内存可以使用选项+UseCompressedOops开启指针压缩。UseCompressedOops中的Oop为Ordinary object pointer(普通对象指针)的缩写。
手动开启Oop对象指针压缩的Java指令为:
java -XX:+UseCompressedOops mianclass
手动关闭Oop对象指针压缩的Java指令为:
java -XX:-UseCompressedOops mianclass
2.4.2 Mark Word的结构信息
Java内置锁的状态总共有4种,级别由低到高依次为:无锁、偏向锁、轻量级锁和重量级锁。其实在JDK 1.6之前,Java内置锁还是一个重量级锁,是一个效率比较低下的锁,在JDK 1.6之后,JVM为了提高锁的获取与释放效率,对synchronized的实现进行了优化,引入了偏向锁和轻量级锁,从此以后Java内置锁的状态就有了4种(无锁、偏向锁、轻量级锁和重量级锁),并且4种状态会随着竞争的情况逐渐升级,而且是不可逆的过程,即不可降级,也就是说只能进行锁升级(从低级别到高级别)。
1.不同锁状态
Mark Word字段的结构与Java内置锁的状态强相关。为了让Mark Word字段存储更多的信息,JVM将Mark Word最低两个位设置为Java内置锁状态位,不同锁状态下的32位Mark Word结构如表2-1所示
image-20211104204229348
2.64位Mark Word的构成
由于目前主流的JVM都是64位,因此我们使用64位的MarkWord。接下来对64位的Mark Word中各部分的内容进行具体介绍。
(1)lock:锁状态标记位,占两个二进制位,由于希望用尽可能少的二进制位表示尽可能多的信息,因此设置了lock标记。该标记的值不同,整个Mark Word表示的含义就不同。
(2)biased_lock:对象是否启用偏向锁标记,只占1个二进制位。为1时表示对象启用偏向锁,为0时表示对象没有偏向锁。
lock和biased_lock两个标记位组合在一起共同表示Object实例处于什么样的锁状态。
image-20211104204628425
(3)age:4位的Java对象分代年龄。在GC中,对象在Survivor区复制一次,年龄就增加1。当对象达到设定的阈值时,将会晋升到老年代。默认情况下,并行GC的年龄阈值为15,并发GC的年龄阈值为6。由于age只有4位,因此最大值为15,这就是-XX:MaxTenuringThreshold选项最大值为15的原因。
(4)identity_hashcode:31位的对象标识HashCode(哈希码)采用延迟加载技术,当调用Object.hashCode()方法或者System.identityHashCode()方法计算对象的HashCode后,其结果将被写到该对象头中。当对象被锁定时,该值会移动到Monitor(监视器)中。
5)thread:54位的线程ID值为持有偏向锁的线程ID。
(6)epoch:偏向时间戳。
(7)ptr_to_lock_record:占62位,在轻量级锁的状态下指向栈帧中锁记录的指针。
(8)ptr_to_heavyweight_monitor:占62位,在重量级锁的状态下指向对象监视器的指针。
2.4.5 无锁、偏向锁、轻量级锁和重量级锁
在JDK 1.6版本中内置锁一共有4种状态:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这些状态随着竞争情况逐渐升级。内置锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能再降级成偏向锁。这种能升级却不能降级的策略,其目的是提高获得锁和释放锁的效率。
1.无锁状态
Java对象刚创建时还没有任何线程来竞争,说明该对象处于无锁状态(无线程竞争它),这时偏向锁标识位是0,锁状态是01。无锁状态下对象的Mark Word如图2-7所示。
image-20211104205423027
2.偏向锁状态
偏向锁是指一段同步代码一直被同一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。如果内置锁处于偏向状态,当有一个线程来竞争锁时,先用偏向锁,表示内置锁偏爱这个线程,这个线程要执行该锁关联的同步代码时,不需要再做任何检查和切换。偏向锁在竞争不激烈的情况下效率非常高。
偏向锁状态的Mark Word会记录内置锁自己偏爱的线程ID,内置锁会将该线程当作自己的熟人。偏向锁状态下对象的MarkWord如图2-8所示。
image-20211104205545392
3.轻量级锁状态
当有两个线程开始竞争这个锁对象时,情况就发生变化了,不再是偏向(独占)锁了,锁会升级为轻量级锁,两个线程公平竞争,哪个线程先占有锁对象,锁对象的Mark Word就指向哪个线程的栈帧中的锁记录。轻量级锁状态下对象的Mark Word如图2-9所示。
image-20211104205643779
当锁处于偏向锁,又被另一个线程企图抢占时,偏向锁就会升级为轻量级锁。企图抢占的线程会通过自旋的形式尝试获取锁,不会阻塞抢锁线程,以便提高性能。
但是,线程自旋是需要消耗CPU的,如果一直获取不到锁,那么线程也不能一直占用CPU自旋做无用功,所以需要设定一个自旋等待的最大时间。JVM对于自旋周期的选择,JDK 1.6之后引入了适应性自旋锁,适应性自旋锁意味着自旋的时间不是固定的,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定的。线程如果自旋成功了,下次自旋的次数就会更多,如果自旋失败了,自旋的次数就会减少。
如果持有锁的线程执行的时间超过自旋等待的最大时间仍没有释放锁,就会导致其他争用锁的线程在最大等待时间内还是获取不到锁,自旋不会一直持续下去,这时争用线程会停止自旋进入阻塞状态,该锁膨胀为重量级锁。
4.重量级锁状态
重量级锁会让其他申请的线程之间进入阻塞,性能降低。重量级锁也叫同步锁,这个锁对象Mark Word再次发生变化,会指向一个监视器对象,该监视器对象用集合的形式来登记和管理排队的线程。重量级锁状态下对象的Mark Word如图2-10所示。
image-20211104205926849
2.5 偏向锁的原理与实战
偏向锁主要解决无竞争下的锁性能问题,所谓的偏向就是偏心,即锁会偏向于当前已经占有锁的线程。
2.5.1 偏向锁的核心原理
在实际场景中,如果一个同步块(或方法)没有多个线程竞争,而且总是由同一个线程多次重入获取锁,如果每次还有阻塞线程,唤醒CPU从用户态转为核心态,那么对于CPU是一种资源的浪费,为了解决这类问题,就引入了偏向锁的概念。
偏向锁的核心原理是:如果不存在线程竞争的一个线程获得了锁,那么锁就进入偏向状态,此时Mark Word的结构变为偏向锁结构,锁对象的锁标志位(lock)被改为01,偏向标志位(biased_lock)被改为1,然后线程的ID记录在锁对象的MarkWord中(使用CAS操作完成)。以后该线程获取锁时判断一下线程ID和标志位,就可以直接进入同步块,连CAS操作都不需要,这样就省去了大量有关锁申请的操作,从而也就提升了程序的性能。
假如在大部分情况下同步块是没有竞争的,那么可以通过偏向来提高性能。即在无竞争时,之前获得锁的线程再次获得锁时会判断偏向锁的线程ID是否指向自己,如果是,那么该线程将不用再次获得锁,直接就可以进入同步块;如果未指向当前线程,当前线程就会采用CAS操作将Mark Word中的线程ID设置为当前线程ID,如果CAS操作成功,那么获取偏向锁成功,执行同步代码块,如果CAS操作失败,那么表示有竞争,抢锁线程被挂起,撤销占锁线程的偏向锁,然后将偏向锁膨胀为轻量级锁。
偏向锁的加锁过程为:新线程只需要判断内置锁对象的MarkWord中的线程ID是不是自己的ID,如果是就直接使用这个锁,而不使用CAS交换;如果不是,比如在第一次获得此锁时内置锁的线程ID为空,就使用CAS交换,新线程将自己的线程ID交换到内置锁的Mark Word中,如果交换成功,就加锁成功。
2.5.3 偏向锁的膨胀和撤销
假如有多个线程来竞争偏向锁,此对象锁已经有所偏向,其他的线程发现偏向锁并不是偏向自己,就说明存在了竞争,尝试撤销偏向锁(很可能引入安全点),然后膨胀到轻量级锁。
1.偏向锁的撤销
偏向锁撤销的开销花费还是挺大的,其大概过程如下:
(1)在一个安全点停止拥有锁的线程。
(2)遍历线程的栈帧,检查是否存在锁记录。如果存在锁记录,就需要清空锁记录,使其变成无锁状态,并修复锁记录指向的Mark Word,清除其线程ID。
(3)将当前锁升级成轻量级锁
(4)唤醒当前线程。
所以,如果某些临界区存在两个及两个以上的线程竞争,那么偏向锁反而会降低性能。在这种情况下,可以在启动JVM时就把偏向锁的默认功能关闭。
撤销偏向锁的条件:
(1)多个线程竞争偏向锁。
(2)调用偏向锁对象的hashcode()方法或者System.identityHashCode()方法计算对象的HashCode之后,将哈希码放置到Mark Word中,内置锁变成无锁状态,偏向锁将被撤销。
2.偏向锁的膨胀
如果偏向锁被占据,一旦有第二个线程争抢这个对象,因为偏向锁不会主动释放,所以第二个线程可以看到内置锁偏向状态,这时表明在这个对象锁上已经存在竞争了。JVM检查原来持有该对象锁的占有线程是否依然存活,如果挂了,就可以将对象变为无锁状态,然后进行重新偏向,偏向为抢锁线程。
如果JVM检查到原来的线程依然存活,就进一步检查占有线程的调用堆栈是否通过锁记录持有偏向锁。如果存在锁记录,就表明原来的线程还在使用偏向锁,发生锁竞争,撤销原来的偏向锁,将偏向锁膨胀(INFLATING)为轻量级锁。
3.偏向锁的好处
经验表明,其实大部分情况下进入一个同步代码块的线程都是同一个线程。这也是JDK会引入偏向锁的原因。所以,总体来说,使用偏向锁带来的好处还是大于偏向锁撤销和膨胀所带来的代价。
2.6 轻量级锁的原理与实战
引入轻量级锁的主要目的是在多线程竞争不激烈的情况下,通过CAS机制竞争锁减少重量级锁产生的性能损耗。重量级锁使用了操作系统底层的互斥锁(Mutex Lock),会导致线程在用户态和核心态之间频繁切换,从而带来较大的性能损耗。
2.6.1 轻量级锁的核心原理
轻量锁存在的目的是尽可能不动用操作系统层面的互斥锁,因为其性能比较差。线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁地阻塞和唤醒对CPU来说是一件负担很重的工作。同时我们可以发现,很多对象锁的锁定状态只会持续很短的一段时间,例如整数的自加操作,在很短的时间内阻塞并唤醒线程显然不值得,为此引入了轻量级锁。轻量级锁是一种自旋锁,因为JVM本身就是一个应用,所以希望在应用层面上通过自旋解决线程同步问题。
轻量级锁的执行过程:在抢锁线程进入临界区之前,如果内置锁(临界区的同步对象)没有被锁定,JVM首先将在抢锁线程的栈帧中建立一个锁记录(Lock Record),用于存储对象目前Mark Word的拷贝,这时的线程堆栈与内置锁对象头大致如图2-11所示。
轻量级锁的执行过程:在抢锁线程进入临界区之前,如果内置锁(临界区的同步对象)没有被锁定,JVM首先将在抢锁线程的栈帧中建立一个锁记录(Lock Record),用于存储对象目前Mark Word的拷贝,这时的线程堆栈与内置锁对象头大致如图
image-20211104212251920
然后抢锁线程将使用CAS自旋操作,尝试将内置锁对象头的Mark Word的ptrtolock_record(锁记录指针)更新为抢锁线程栈帧中锁记录的地址,如果这个更新执行成功了,这个线程就拥有了这个对象锁。然后JVM将Mark Word中的lock标记位改为00(轻量级锁标志),即表示该对象处于轻量级锁状态。抢锁成功之后,JVM会将Mark Word中原来的锁对象信息(如哈希码等)保存在抢锁线程锁记录的Displaced MarkWord(可以理解为放错地方的Mark Word)字段中,再将抢锁线程中锁记录的owner指针指向锁对象。
在轻量级锁抢占成功之后,锁记录和对象头的状态如图2-12所示。
image-20211104212541367
锁记录是线程私有的,每个线程都有自己的一份锁记录,在创建完锁记录后,会将内置锁对象的Mark Word复制到锁记录的Displaced Mark Word字段。这是为什么呢?因为内置锁对象的Mark Word的结构会有所变化,Mark Word将会出现一个指向锁记录的指针,而不再存着无锁状态下的锁对象哈希码等信息,所以必须将这些信息暂存起来,供后面在锁释放时使用。
2.6.3 轻量级锁的分类
轻量级锁主要有两种:普通自旋锁和自适应自旋锁。
1.普通自旋锁
所谓普通自旋锁,就是指当有线程来竞争锁时,抢锁线程会在原地循环等待,而不是被阻塞,直到那个占有锁的线程释放锁之后,这个抢锁线程才可以获得锁。
锁在原地循环等待的时候是会消耗CPU的,就相当于在执行一个什么也不干的空循环。所以轻量级锁适用于临界区代码耗时很短的场景,这样线程在原地等待很短的时间就能够获得锁了。
默认情况下,自旋的次数为10次,用户可以通过-XX:PreBlockSpin选项来进行更改。
2.自适应自旋锁
所谓自适应自旋锁,就是等待线程空循环的自旋次数并非是固定的,而是会动态地根据实际情况来改变自旋等待的次数,自旋次数由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。自适应自旋锁的大概原理是:
(1)如果抢锁线程在同一个锁对象上之前成功获得过锁,JVM就会认为这次自旋很有可能再次成功,因此允许自旋等待持续相对更长的时间。
(2)如果对于某个锁,抢锁线程很少成功获得过,那么JVM将可能减少自旋时间甚至省略自旋过程,以避免浪费处理器资源。
自适应自旋解决的是“锁竞争时间不确定”的问题。自适应自旋假定不同线程持有同一个锁对象的时间基本相当,竞争程度趋于稳定。总的思想是:根据上一次自旋的时间与结果调整下一次自旋的时间。
轻量级锁也被称为非阻塞同步、乐观锁,因为这个过程并没有把线程阻塞挂起,而是让线程空循环等待。
2.6.4 轻量级锁的膨胀
轻量级锁的问题在哪里呢?虽然大部分临界区代码的执行时间都是很短的,但是也会存在执行得很慢的临界区代码。临界区代码执行耗时较长,在其执行期间,其他线程都在原地自旋等待,会空消耗CPU。因此,如果竞争这个同步锁的线程很多,就会有多个线程在原地等待继续空循环消耗CPU(空自旋),这会带来很大的性能损耗。
轻量级锁的本意是为了减少多线程进入操作系统底层的互斥锁(Mutex Lock)的概率,并不是要替代操作系统互斥锁。所以,在争用激烈的场景下,轻量级锁会膨胀为基于操作系统内核互斥锁实现的重量级锁。
2.7 重量级锁的原理与实战
在JVM中,每个对象都关联一个监视器,这里的对象包含Object实例和Class实例。监视器是一个同步工具,相当于一个许可证,拿到许可证的线程即可进入临界区进行操作,没有拿到则需要阻塞等待。重量级锁通过监视器的方式保障了任何时间只允许一个线程通过受到监视器保护的临界区代码。
2.7.1 重量级锁的核心原理
JVM中每个对象都会有一个监视器,监视器和对象一起创建、销毁。监视器相当于一个用来监视这些线程进入的特殊房间,其义务是保证(同一时间)只有一个线程可以访问被保护的临界区代码块。
本质上,监视器是一种同步工具,也可以说是一种同步机制,主要特点是:
(1)同步。监视器所保护的临界区代码是互斥地执行的。一个监视器是一个运行许可,任一线程进入临界区代码都需要获得这个许可,离开时把许可归还。
(2)协作(多个线程之间的通信)。监视器提供Signal机制,允许正持有许可的线程暂时放弃许可进入阻塞等待状态,等待其他线程发送Signal去唤醒;其他拥有许可的线程可以发送Signal,唤醒正在阻塞等待的线程,让它可以重新获得许可并启动执行。
在Hotspot虚拟机中,监视器是由C++类ObjectMonitor实现的,ObjectMonitor的Owner(owner)、WaitSet(WaitSet)、Cxq(cxq)、EntryList(EntryList)这几个属性比较关键。ObjectMonitor的WaitSet、Cxq、EntryList这三个队列存放抢夺重量级锁的线程,而ObjectMonitor的Owner所指向的线程即为获得锁的线程。
Cxq、EntryList、WaitSet这三个队列的说明如下:
(1)Cxq:竞争队列(Contention Queue),所有请求锁的线程首先被放在这个竞争队列中。
(2)EntryList:Cxq中那些有资格成为候选资源的线程被移动到EntryList中。
(3)WaitSet:某个拥有ObjectMonitor的线程在调用Object.wait()方法之后将被阻塞,然后该线程将被放置在WaitSet链表中。
ObjectMonitor的内部抢锁过程如图2-13所示。
image-20211104224457192
1.Cxq
Cxq并不是一个真正的队列,只是一个虚拟队列,原因在于Cxq是由Node及其next指针逻辑构成的,并不存在一个队列的数据结构。每次新加入Node会在Cxq的队头进行,通过CAS改变第一个节点的指针为新增节点,同时设置新增节点的next指向后续节点;从Cxq取得元素时,会从队尾获取。显然,Cxq结构是一个无锁结构。
因为只有Owner线程才能从队尾取元素,即线程出列操作无争用,当然也就避免了CAS的ABA问题。
在线程进入Cxq前,抢锁线程会先尝试通过CAS自旋获取锁,如果获取不到,就进入Cxq队列,这明显对于已经进入Cxq队列的线程是不公平的。所以,synchronized同步块所使用的重量级锁是不公平锁。
2.EntryList
EntryList与Cxq在逻辑上都属于等待队列。Cxq会被线程并发访问,为了降低对Cxq队尾的争用,而建立EntryList。在Owner线程释放锁时,JVM会从Cxq中迁移线程到EntryList,并会指定EntryList中的某个线程(一般为Head)为OnDeck Thread(Ready Thread)。EntryList中的线程作为候选竞争线程而存在。
3.OnDeck Thread与Owner Thread
JVM不直接把锁传递给Owner Thread,而是把锁竞争的权利交给OnDeck Thread,OnDeck需要重新竞争锁。这样虽然牺牲了一些公平性,但是能极大地提升系统的吞吐量,在JVM中,也把这种选择行为称为“竞争切换”。
OnDeck Thread获取到锁资源后会变为Owner Thread。无法获得锁的OnDeck Thread则会依然留在EntryList中,考虑到公平性,OnDeck Thread在EntryList中的位置不发生变化(依然在队头)。
在OnDeck Thread成为Owner的过程中,还有一个不公平的事情,就是后来的新抢锁线程可能直接通过CAS自旋成为Owner而抢到锁。
4.WaitSet
如果Owner线程被Object.wait()方法阻塞,就转移到WaitSet队列中,直到某个时刻通过Object.notify()或者Object.notifyAll()唤醒,该线程就会重新进入EntryList中。
2.7.2 重量级锁的开销
处于ContentionList、EntryList、WaitSet中的线程都处于阻塞状态,线程的阻塞或者唤醒都需要操作系统来帮忙,Linux内核下采用pthreadmutexlock系统调用实现,进程需要从用户态切换到内核态。
image-20211104225341129
Linux系统的内核是一组特殊的软件程序,负责控制计算机的硬件资源,例如协调CPU资源、分配内存资源,并且提供稳定的环境供应用程序运行。应用程序的活动空间为用户空间,应用程序的执行必须依托于内核提供的资源,包括CPU资源、存储资源、I/O资源等。
用户态与内核态有各自专用的内存空间、专用的寄存器等,进程从用户态切换至内核态需要传递许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。
用户态的进程能够访问的资源受到了极大的控制,而运行在内核态的进程可以“为所欲为”。一个进程可以运行在用户态,也可以运行在内核态,那么肯定存在用户态和内核态切换的过程。进程从用户态到内核态切换主要包括以下三种方式:
(1)硬件中断。硬件中断也称为外设中断,当外设完成用户的请求时会向CPU发送中断信号。
(2)系统调用。其实系统调用本身就是中断,只不过是软件中断,跟硬件中断不同。
(3)异常。如果当前进程运行在用户态,这个时候发生了异常事件(例如缺页异常),就会触发切换。
用户态是应用程序运行的空间,为了能访问到内核管理的资源(例如CPU、内存、I/O),可以通过内核态所提供的访问接口实现,这些接口就叫系统调用。pthreadmutexlock系统调用是内核态为用户态进程提供的Linux内核态下互斥锁的访问机制,所以使用pthreadmutexlock系统调用时,进程需要从用户态切换到内核态,而这种切换是需要消耗很多时间的,有可能比用户执行代码的时间还要长。
由于JVM轻量级锁使用CAS进行自旋抢锁,这些CAS操作都处于用户态下,进程不存在用户态和内核态之间的运行切换,因此JVM轻量级锁开销较小。而JVM重量级锁使用了Linux内核态下的互斥锁,这是重量级锁开销很大的原因。
2.8 偏向锁、轻量级锁与重量级锁的对比
总结一下synchronized的执行过程,大致如下:
(1)线程抢锁时,JVM首先检测内置锁对象Mark Word中的biased_lock(偏向锁标识)是否设置成1,lock(锁标志位)是否为01,如果都满足,确认内置锁对象为可偏向状态。
(2)在内置锁对象确认为可偏向状态之后,JVM检查MarkWord中的线程ID是否为抢锁线程ID,如果是,就表示抢锁线程处于偏向锁状态,抢锁线程快速获得锁,开始执行临界区代码。
(3)如果Mark Word中的线程ID并未指向抢锁线程,就通过CAS操作竞争锁。如果竞争成功,就将Mark Word中的线程ID设置为抢锁线程,偏向标志位设置为1,锁标志位设置为01,然后执行临界区代码,此时内置锁对象处于偏向锁状态。
(4)如果CAS操作竞争失败,就说明发生了竞争,撤销偏向锁,进而升级为轻量级锁。
(5)JVM使用CAS将锁对象的Mark Word替换为抢锁线程的锁记录指针,如果成功,抢锁线程就获得锁。如果替换失败,就表示其他线程竞争锁,JVM尝试使用CAS自旋替换抢锁线程的锁记录指针,如果自旋成功(抢锁成功),那么锁对象依然处于轻量级锁状态。
(6)如果JVM的CAS替换锁记录指针自旋失败,轻量级锁就膨胀为重量级锁,后面等待锁的线程也要进入阻塞状态。
总体来说,偏向锁是在没有发生锁争用的情况下使用的;一旦有了第二个线程争用锁,偏向锁就会升级为轻量级锁;如果锁争用很激烈,轻量级锁的CAS自旋到达阈值后,轻量级锁就会升级为重量级锁。三种内置锁的对比如表2-6所示。
image-20211104230225018
image-20211104230240264
2.9 线程间通信
线程是操作系统调度的最小单位,有自己的栈空间,可以按照既定的代码逐步执行,但是如果每个线程间都孤立地运行,就会造资源浪费。所以在现实中,如果需要多个线程按照指定的规则共同完成一个任务,那么这些线程之间就需要互相协调,这个过程被称为线程的通信。
2.9.1 线程间通信的定义
线程的通信可以被定义为:当多个线程共同操作共享的资源时,线程间通过某种方式互相告知自己的状态,以避免无效的资源争夺。线程间通信的方式可以有很多种:等待-通知、共享内存、管道流。每种方式用不同的方法来实现,这里首先介绍等待-通知的通信方式。
Java语言中“等待-通知”方式的线程间通信使用对象的wait()、notify()两类方法来实现。每个Java对象都有wait()、notify()两类实例方法,并且wait()、notify()方法和对象的监视器是紧密相关的。
Wait()、notify()两类方法在数量上不止两个。wait()、notify()两类方法不属于Thread类,而是属于Java对象实例(Object实例或者Class实例)。
2.9.3 wait方法和notify方法的原理
1.对象的wait()方法
对象的wait()方法的主要作用是让当前线程阻塞并等待被唤醒。wait()方法与对象监视器紧密相关,使用wait()方法时一定要放在同步块中。Wait()方法的调用方法如下
syncroized(lockObject)
{
lockObject.wait();
}
Object类中的wait()方法有三个版本:
(1)void wait()
这是一个基础版本,当前线程调用了同步对象locko的wait()实例方法后,将导致当前的线程等待,当前线程进入locko的监视器WaitSet,等待被其他线程唤醒。
(2)void wait(long timeout)
这是一个限时等待版本,导致当前的线程等待,等待被其他线程唤醒,或者指定的时间timeout用完,线程不再等待。
(3)void wait(long timeout,int nanos)
这是一个高精度限时等待版本,其主要作用是更精确地控制等待时间。参数nanos是一个附加的纳秒级别的等待时间,从而实现更加高精度的等待时间控制。
2.wait()方法的核心原理
对象的wait()方法的核心原理大致如下:
(1)当线程调用了locko(某个同步锁对象)的wait()方法后,JVM会将当前线程加入locko监视器的WaitSet(等待集),等待被其他线程唤醒。
(2)当前线程会释放locko对象监视器的Owner权利,让其他线程可以抢夺locko对象的监视器。
(3)让当前线程等待,其状态变成WAITING。
3.对象的notify()方法
对象的notify()方法的主要作用是唤醒在等待的线程。notify()方法与对象监视器紧密相关,调用notify()方法时也需要放在同步块中。notify()方法的调用方法如下:
synchroinzed(lockObject)
{
lockObjec.notify();
}
image-20211104231620095
图2-15 同步对象locko的wait()方法被调用之后其监视器的内部状态
notify()方法有两个版本:
版本一:void notify()notify()方法的主要作用为:locko.notify()调用后,唤醒locko监视器等待集中的第一条等待线程;被唤醒的线程进入EntryList,其状态从WAITING变成BLOCKED。
版本二:void notifyAll()locko.notifyAll()被调用后,唤醒locko监视器等待集中的全部等待线程,所有被唤醒的线程进入EntryList,线程状态从WAITING变成BLOCKED。
4.notify()方法的核心原理
对象的notify()或者notifyAll()方法的核心原理大致如下:
(1)当线程调用了locko(某个同步锁对象)的notify()方法后,JVM会唤醒locko监视器WaitSet中的第一条等待线程。
(2)当线程调用了locko的notifyAll()方法后,JVM会唤醒locko监视器WaitSet中的所有等待线程。
(3)等待线程被唤醒后,会从监视器的WaitSet移动到EntryList,线程具备了排队抢夺监视器Owner权利的资格,其状态从WAITING变成BLOCKED。
(4)EntryList中的线程抢夺到监视器的Owner权利之后,线程的状态从BLOCKED变成Runnable,具备重新执行的资格。
image-20211104231924883
图2-16 同步对象locko的wait()或者notifyAll()方法被调之后监视器的内部状态
调用wait()和notify()系列方法进行线程通信的要点如下:
(1)调用某个同步对象locko的wait()和notify()类型方法前,必须要取得这个锁对象的监视锁,所以wait()和notify()类型方法必须放在synchronized(locko)同步块中,如果没有获得监视锁,JVM就会报IllegalMonitorStateException异常。
(2)调用wait()方法时使用while进行条件判断,如果是在某种条件下进行等待,对条件的判断就不能使用if语句做一次性判断,而是使用while循环进行反复判断。只有这样才能在线程被唤醒后继续检查wait的条件,并在条件没有满足的情况下继续等待。
第3章 CAS原理与JUC原子类
由于JVM的Synchronized重量级锁涉及操作系统(如Linux)内核态下互斥锁的使用,因此其线程阻塞和唤醒都涉及进程在用户态到内核态的频繁切换,导致重量级锁开销大、性能低。而JVM的Synchronized轻量级锁使用CAS(Compare AndSwap,比较并交换)进行自旋抢锁,CAS是CPU指令级的原子操作,并处于用户态下,所以JVM轻量级锁的开销较小。
3.1 什么是CAS
3.1.1 Unsafe类中的CAS方法
Unsafe是位于sun.misc包下的一个类,主要提供一些用于执行低级别、不安全的底层操作,如直接访问系统内存资源、自主管理内存资源等。Unsafe大量的方法都是native方法,基于C++语言实现,这些方法在提升Java运行效率、增强Java语言底层资源操作能力方面起到了很大的作用。
Unsafe类的全限定名为sun.misc.Unsafe,从名字中可以看出这个类对普通程序员来说是“危险”的,一般的应用开发都不会涉及此类,Java官方也不建议直接在应用程序中使用这些类。
操作系统层面的CAS是一条CPU的原子指令(cmpxchg指令),正是由于该指令具备原子性,因此使用CAS操作数据时不会造成数据不一致的问题,Unsafe提供的CAS方法直接通过native方式(封装C++代码)调用了底层的CPU指令cmpxchg。
完成Java应用层的CAS操作主要涉及Unsafe方法的调用,具体如下:
(1)获取Unsafe实例。
(2)调用Unsafe提供的CAS方法,这些方法主要封装了底层CPU的CAS原子操作。
(3)调用Unsafe提供的字段偏移量方法,这些方法用于获取对象中的字段(属性)偏移量,此偏移量值需要作为参数提供给CAS操作。
1.获取Unsafe实例
Unsafe类是一个final修饰的不允许继承的最终类,而且其构造函数是private类型的方法,具体的源码如下:
@CallerSensitive
public static Unsafe getUnsafe() {
Class var0 = Reflection.getCallerClass();
if (!VM.isSystemDomainLoader(var0.getClassLoader())) {
throw new SecurityException(“Unsafe”);
} else {
return theUnsafe;
}
}
2.调用Unsafe提供的CAS方法
public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);
Unsafe提供的CAS方法包含4个操作数——字段所在的对象、字段内存位置、预期原值及新值。在执行Unsafe的CAS方法时,这些方法首先将内存位置的值与预期值(旧的值)比较,如果相匹配,那么CPU会自动将该内存位置的值更新为新值,并返回true;如果不匹配,CPU不做任何操作,并返回false。
Unsafe的CAS操作会将第一个参数(对象的指针、地址)与第二个参数(字段偏移量)组合在一起,计算出最终的内存操作地址。
3.调用Unsafe提供的偏移量相关
Unsafe提供的获取字段(属性)偏移量的相关操作主要如下:
public native long staticFieldOffset(Field var1);
public native long objectFieldOffset(Field var1);
staticFieldOffset()方法用于获取静态属性Field在Class对象中的偏移量,在CAS中操作静态属性时会用到这个偏移量。objectFieldOffset()方法用于获取非静态Field(非静态属性)在Object实例中的偏移量,在CAS中操作对象的非静态属性时会用到这个偏移量。
一个获取非静态Field(非静态属性)在Object实例中的偏移量的示例代码如下:
image-20211108215847886
3.1.2 使用CAS进行无锁编程
CAS是一种无锁算法,该算法关键依赖两个值——期望值(旧值)和新值,底层CPU利用原子操作判断内存原值与期望值是否相等,如果相等就给内存地址赋新值,否则不做任何操作。
使用CAS进行无锁编程的步骤大致如下:
(1)获得字段的期望值(oldValue)。
(2)计算出需要替换的新值(newValue)。
(3)通过CAS将新值(newValue)放在字段的内存地址上,如果CAS失败就重复第(1)步到第(2)步,一直到CAS成功,这种重复俗称CAS自旋。
如果一直是失败的,那就是空自旋,非常消耗性能。
使用CAS进行无锁编程的伪代码如下:
image-20211108220246844
3.1.4 字段偏移量的计算
调用Unsafe.objectFieldOffset(…)方法获取到的Object字段(也叫Object成员属性)的偏移量值是字段相对于Object头部的偏移量,是一个相对的内存地址值,不是绝对的内存地址值。
首先回顾一下3.1.3节用到的OptimisticLockingPlus类,该类所包含的字段如下:
虽然OptimisticLockingPlus类有5个字段,但是其中有4个是静态字段,属于类的成员而不是对象的成员,真正属于对象的字段只有其中的value字段。所以,一个OptimisticLockingPlus类的对象结构,如图3-3所示。
image-20211108221555530
通过图3-3可以看出,在64位的JVM堆区中一个OptimisticLockingPlus对象的Object Header(头部)占用了12字节,其中Mark Word占用了8字节(64位),压缩过的Class Pointer占用了4字节。接在Object Header之后的就是成员属性value的内存区域,所以value属性相对于ObjectHeader的偏移量为12。
3.2 JUC原子类
在多线程并发执行时,诸如“++”或“–”类的运算不具备原子性,不是线程安全的操作。通常情况下,大家会使用synchronized将这些线程不安全的操作变成同步操作,但是这样会降低并发程序的性能。所以,JDK为这些类型不安全的操作提供了一些原子类,与synchronized同步机制相比,JDK原子类是基于CAS轻量级原子操作的实现,使得程序运行效率变得更高。
3.2.1 JUC中的Atomic原子操作包
Atomic操作翻译成中文是指一个不可中断的操作,即使在多个线程一起执行Atomic类型操作的时候,一个操作一旦开始,就不会被其他线程中断。所谓Atomic类,指的是具有原子操作特征的类。
根据操作的目标数据类型,可以将JUC包中的原子类分为4类:基本原子类、数组原子类、原子引用类和字段更新原子类。
(1)基本原子类
基本原子类的功能是通过原子方式更新Java基础类型变量的值。基本原子类主要包括以下三个:·AtomicInteger:整型原子类。·AtomicLong:长整型原子类。·AtomicBoolean:布尔型原子类。
(2)数组原子类
数组原子类的功能是通过原子方式更数组中的某个元素的值。数组原子类主要包括以下三个:·AtomicIntegerArray:整型数组原子类。·AtomicLongArray:长整型数组原子类。·AtomicReferenceArray:引用类型数组原子类
(3)引用原子类
引用原子类主要包括以下三个:·AtomicReference:引用类型原子类。·AtomicMarkableReference:带有更新标记位的原子引用类型。·AtomicStampedReference:带有更新版本号的原子引用类型。AtomicMarkableReference类将boolean标记与引用关联起来,可以解决使用AtomicBoolean进行原子更新时可能出现的ABA问题。AtomicStampedReference类将整数值与引用关联起来,可以解决使用AtomicInteger进行原子更新时可能出现的ABA问题。
(4)字段更新原子类
字段更新原子类主要包括以下三个:·AtomicIntegerFieldUpdater:原子更新整型字段的更新器。·AtomicLongFieldUpdater:原子更新长整型字段的更新器。·AtomicReferenceFieldUpdater:原子更新引用类型中的字段。
3.2.2 基础原子类AtomicInteger
基础原子类AtomicInteger常用的方法如下:
// 获取当前的值
public final int get() {
return value;
}
// 获取当前的值并设置新的值
public final int getAndSet(int newValue) {
return unsafe.getAndSetInt(this, valueOffset, newValue);
}
// 获取当前的值并自增
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
// 获取当前的值 并自减
public final int getAndDecrement() {
return unsafe.getAndAddInt(this, valueOffset, -1);
}
// 获取当前的值并加上 delta
public final int getAndAdd(int delta) {
return unsafe.getAndAddInt(this, valueOffset, delta);
}
// 通过CAS方式设置整数值
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
3.2.4 AtomicInteger线程安全原理
基础原子类(以AtomicInteger为例)主要通过CAS自旋+volatile的方案实现,既保障了变量操作的线程安全性,又避免了synchronized重量级锁的高开销,使得Java程序的执行效率大为提升。
AtomicInteger源码中的主要方法都是通过CAS自旋实现的。CAS自旋的主要操作为:如果一次CAS操作失败,获取最新的value值后,再次进行CAS操作,直到成功。
另外,AtomicInteger所包装的内部value成员是一个使用关键字volatile修饰的内部成员。关键字volatile的原理比较复杂,简单地说,该关键字可以保证任何线程在任何时刻总能拿到该变量的最新值,其目的在于保障变量值的线程可见性。
AtomicInteger源代码使用了Unsafe这个类,就是CAS自旋锁的关键。
3.3 对象操作的原子性
基础的原子类型只能保证一个变量的原子操作,当需要对多个变量进行操作时,CAS无法保证原子性操作,这时可以用AtomicReference(原子引用类型)保证对象引用的原子性。
3.3.1 引用类型原子类
引用类型原子类包括以下种:
·AtomicReference:基础的引用原子类。
·AtomicStampedReference:带印戳的引用原子类。 解决CAS自旋锁带来的ABA问题,后面会说
·AtomicMarkableReference:带修改标志的引用原子类。
使用原子引用类型AtomicReference包装了User对象之后,只能保障User引用的原子操作,对被包装的User对象的字段值修改时不能保证原子性,这点要切记。
3.3.2 属性更新原子类
如果需要保障对象某个字段(或者属性)更新操作的原子性,就需要用到属性更新原子类。属性更新原子类有以下三个:
·AtomicIntegerFieldUpdater:保障整型字段的更新操作的原子性。
·AtomicLongFieldUpdater:保障长整型字段的更新操作的原子性。
·AtomicReferenceFieldUpdater:保障引用字段的更新操作的原子性。
使用属性更新原子类保障属性安全更新的流程大致需要两步:
·第一步,更新的对象属性必须使用public volatile修饰符。
·第二步,因为对象的属性修改类型原子类都是抽象类,所以每次使用都必须调用静态方法newUpdater()创建一个更新器,并且需要设置想要更新的类和属性。
3.4 ABA问题
由于CAS原子操作性能高,因此其在JUC包中被广泛应用,只不过如果使用得不合理,CAS原子操作就会存在ABA问题。
3.4.1 了解ABA问题
什么是ABA问题?举一个例子来说明。比如一个线程A从内存位置M中取出V1,另一个线程B也取出V1。现在假设线程B进行了一些操作之后将M位置的数据V1变成了V2,然后又在一些操作之后将V2变成了V1。之后,线程A进行CAS操作,但是线程A发现M位置的数据仍然是V1,然后线程A操作成功。尽管线程A的CAS操作成功,但是不代表这个过程是没有问题的,线程A操作的数据V1可能已经不是之前的V1,而是被线程B替换过的V1,这就是ABA问题。
3.4.2 ABA问题解决方案
很多乐观锁的实现版本都是使用版本号(Version)方式来解决ABA问题。乐观锁每次在执行数据的修改操作时都会带上一个版本号,版本号和数据的版本号一致就可以执行修改操作并对版本号执行加1操作,否则执行失败。因为每次操作的版本号都会随之增加,所以不会出现ABA问题,因为版本号只会增加,不会减少。
3.4.3 使用AtomicStampedReference解决ABA问题
参考乐观锁的版本号,JDK提供了一个AtomicStampedReference类来解决ABA问题。AtomicStampReference在CAS的基础上增加了一个Stamp(印戳或标记),使用这个印戳可以用来觉察数据是否发生变化,给数据带上了一种实效性的检验。
AtomicStampReference的compareAndSet()方法首先检查当前的对象引用值是否等于预期引用,并且当前印戳(Stamp)标志是否等于预期标志,如果全部相等,就以原子方式将引用值和印戳(Stamp)标志的值更新为给定的更新值。
public class AtomicStampedReference {
private static class Pair {
final T reference;
final int stamp;
private Pair(T reference, int stamp) {
this.reference = reference;
this.stamp = stamp;
}
static Pair of(T reference, int stamp) {
return new Pair(reference, stamp);
}
}
private volatile Pair pair;
// 构造函数 V是原始数据,initialStamp是版本时间印戳
public AtomicStampedReference(V initialRef, int initialStamp) {
pair = Pair.of(initialRef, initialStamp);
}
//取出被封装的数据
public V getReference() {
return pair.reference;
}
/**
* Returns the current value of the stamp. 获取版本号
*
* @return the current value of the stamp
*/
public int getStamp() {
return pair.stamp;
}
public V get(int[] stampHolder) {
Pair pair = this.pair;
stampHolder[0] = pair.stamp;
return pair.reference;
}
public boolean weakCompareAndSet(V expectedReference,
V newReference,
int expectedStamp,
int newStamp) {
return compareAndSet(expectedReference, newReference,
expectedStamp, newStamp);
}
// expectedReference预期引用值,newReference新的值
public boolean compareAndSet(V expectedReference,
V newReference,
int expectedStamp,
int newStamp) {
Pair current = pair;
return
expectedReference == current.reference &&
expectedStamp == current.stamp &&
((newReference == current.reference &&
newStamp == current.stamp) ||
casPair(current, Pair.of(newReference, newStamp)));
}
public void set(V newReference, int newStamp) {
Pair current = pair;
if (newReference != current.reference || newStamp != current.stamp)
this.pair = Pair.of(newReference, newStamp);
}
public boolean attemptStamp(V expectedReference, int newStamp) {
Pair current = pair;
return
expectedReference == current.reference &&
(newStamp == current.stamp ||
casPair(current, Pair.of(expectedReference, newStamp)));
}
// Unsafe mechanics
private static final sun.misc.Unsafe UNSAFE = sun.misc.Unsafe.getUnsafe();
private static final long pairOffset =
objectFieldOffset(UNSAFE, “pair”, AtomicStampedReference.class);
private boolean casPair(Pair cmp, Pair val) {
return UNSAFE.compareAndSwapObject(this, pairOffset, cmp, val);
}
static long objectFieldOffset(sun.misc.Unsafe UNSAFE,
String field, Class<?> klazz) {
try {
return UNSAFE.objectFieldOffset(klazz.getDeclaredField(field));
} catch (NoSuchFieldException e) {
// Convert Exception to corresponding Error
NoSuchFieldError error = new NoSuchFieldError(field);
error.initCause(e);
throw error;
}
}
}
3.4.4 使用AtomicMarkableReference解决ABA问题
AtomicMarkableReference是AtomicStampedReference的简化版,不关心修改过几次,只关心是否修改过。因此,其标记属性mark是boolean类型,而不是数字类型,标记属性mark仅记录值是否修改过。
AtomicMarkableReference适用于只要知道对象是否被修改过,而不适用于对象被反复修改的场景。
3.5 提升高并发场景下CAS操作的性能
在争用激烈的场景下,会导致大量的CAS空自旋。比如,在大量线程同时并发修改一个AtomicInteger时,可能有很多线程会不停地自旋,甚至有的线程会进入一个无限重复的循环中。大量的CAS空自旋会浪费大量的CPU资源,大大降低了程序的性能。
除了存在CAS空自旋之外,在SMP架构的CPU平台上,大量的CAS操作还可能导致“总线风暴”,具体可参见第5章的内容。在高并发场景下如何提升CAS操作的性能呢?可以使用LongAdder替代AtomicInteger。
3.5.1 以空间换时间:LongAdder
Java 8提供了一个新的类LongAdder,以空间换时间的方式提升高并发场景下CAS操作的性能。LongAdder的核心思想是热点分离,与ConcurrentHashMap的设计思想类似:将value值分离成一个数组,当多线程访问时,通过Hash算法将线程映射到数组的一个元素进行操作;而获取最终的value结果时,则将数组的元素求和。
这个怎么理解呢,就是说原来的值是1,增加9次就是10,分成10份,各自为0.1,多线程给0.1各加1,然后求和。分拆计算。
3.5.2 LongAdder的原理
AtomicLong使用内部变量value保存着实际的long值,所有的操作都是针对该value变量进行的。也就是说,在高并发环境下,value变量其实是一个热点,也就是N个线程竞争一个热点。重试线程越多,就意味着CAS的失败概率更高,从而进入恶性CAS空自旋状态。
LongAdder的基本思路是分散热点,将value值分散到一个数组中,不同线程会命中到数组的不同槽(元素)中,各个线程只对自己槽中的那个值进行CAS操作。这样热点就被分散了,冲突的概率就小很多。
1.LongAdder实例的内部结构
LongAdder的内部成员包含一个base值和一个cells数组。在最初无竞争时,只操作base的值;当线程执行CAS失败后,才初始化cells数组,并为线程分配所对应的元素。LongAdder中没有类似于AtomicLong中的getAndIncrement()或者incrementAndGet()这样的原子操作,所以只能通过increment()方法和longValue()方法的组合来实现更新和获取的操作。
image-20211108225916948
看这个图很好理解,就是单线程的时候取base的值,多线程的时候拆分base值为一个数据然后对数据各个元素求和。
2.基类Striped64内部三个重要的成员
Striped64内部包含一个base和一个Cell[]类型的cells数组,cells数组又叫哈希表。在没有竞争的情况下,要累加的数通过CAS累加到base上;如果有竞争的话,会将要累加的数累加到cells数组中的某个Cell元素里面。所以Striped64的整体值value为base+∑[0~n]cells。
Striped64的设计核心思路是通过内部的分散计算来避免竞争,以空间换时间。LongAdder的base类似于AtomicInteger里面的value,在没有竞争的情况下,cells数组为null,这时只使用base进行累加;而一旦发生竞争,cells数组就上场了。
cells数组第一次初始化长度为2,以后每次扩容都变为原来的两倍,一直到cells数组的长度大于等于当前服务器CPU的核数。为什么呢?同一时刻能持有CPU时间片去并发操作同一个内存地址的最大线程数最多也就是CPU的核数。
3.LongAdder类的add()方法
/**
-
Adds the given value.
-
@param x the value to add
*/
public void add(long x) {
Cell[] as; long b, v; int m; Cell a;
if ((as = cells) != null //case1
|| !casBase(b = base, b + x)) { case 2
boolean uncontended = true;
if (as == null || (m = as.length - 1) < 0 || //case3
(a = as[getProbe() & m]) == null case4
||!(uncontended = a.cas(v = a.value, v + x))) case5
longAccumulate(x, null, uncontended);
}
}
首先介绍一下代码中的外层if块的两个条件语句CASE 1和CASE2:·条件语句CASE 1:cells数组不为null,说明存在争用;在不存在争用的时候,cells数组一定为null,一旦对base的cas操作失败,才会初始化cells数组。 ·条件语句CASE 2:如果cells数组为null,表示之前不存在争用,并且此次casBase执行成功,表示基于base成员累加成功,add方法直接返回;如果casBase方法执行失败,说明产生了第一次争用冲突,需要对cells数组初始化,此时即将进入内层if块。 ·条件语句CASE 3:as==null||(m=as.length-1)<0代表cells没有初始化。 ·条件语句CASE 4:指当前线程的hash值在cells数组映射位置的Cell对象为空,意思是还没有其他线程在同一个位置做过累加操作。 ·条件语句CASE 5:指当前线程的哈希值在cells数组映射位置的Cell对象不为空,然后在该Cell对象上进行CAS操作,设置其值为v+x(x为该Cell需要累加的值),但是CAS操作失败,表示存在争用。
如果以上三个条件语句CASE 3、CASE 4、CASE5有一个为真,就进入longAccumulate()方法。
4.LongAdder类中的longAccumulate()方法
longAccumulate()是Striped64中重要的方法,实现不同的线程更新各自Cell中的值,其实现逻辑类似于分段锁,具体的代码如下:
final void longAccumulate(long x, LongBinaryOperator fn,
boolean wasUncontended) {
int h;
if ((h = getProbe()) == 0) {
ThreadLocalRandom.current(); // force initialization
h = getProbe();
wasUncontended = true;
}
boolean collide = false; // True if last slot nonempty
//自旋一直到操作成功
for (;😉 {
//as是数组cells的引用,a是当前线程命中的Cell,n是数组的长度,v表示期望值
Cell[] as; Cell a; int n; long v;
//说明数组cells不为空了
if ((as = cells) != null && (n = as.length) > 0) {
//
if ((a = as[(n - 1) & h]) == null) {
if (cellsBusy == 0) { // Try to attach new Cell
Cell r = new Cell(x); // Optimistically create
if (cellsBusy == 0 && casCellsBusy()) {
boolean created = false;
try { // Recheck under lock
Cell[] rs; int m, j;
if ((rs = cells) != null &&
(m = rs.length) > 0 &&
rs[j = (m - 1) & h] == null) {
rs[j] = r;
created = true;
}
} finally {
cellsBusy = 0;
}
if (created)
break;
continue; // Slot is now non-empty
}
}
collide = false;
}
else if (!wasUncontended) // CAS already known to fail
wasUncontended = true; // Continue after rehash
else if (a.cas(v = a.value, ((fn == null) ? v + x :
fn.applyAsLong(v, x))))
break;
else if (n >= NCPU || cells != as)
collide = false; // At max size or stale
else if (!collide)
collide = true;
else if (cellsBusy == 0 && casCellsBusy()) {
try {
if (cells == as) { // Expand table unless stale
Cell[] rs = new Cell[n << 1];
for (int i = 0; i < n; ++i)
rs[i] = as[i];
cells = rs;
}
} finally {
cellsBusy = 0;
}
collide = false;
continue; // Retry with expanded table
}
h = advanceProbe(h);
}
else if (cellsBusy == 0 && cells == as && casCellsBusy()) {
boolean init = false;
try { // Initialize table
if (cells == as) {
Cell[] rs = new Cell[2];
rs[h & 1] = new Cell(x);
cells = rs;
init = true;
}
} finally {
cellsBusy = 0;
}
if (init)
break;
}
else if (casBase(v = base, ((fn == null) ? v + x :
fn.applyAsLong(v, x))))
break; // Fall back on using base
}
}
longAccumulate的自旋过程中,有三个大的if分支:
·CASE1:表示cells已经初始化了,当前线程应该将数据写入对应的Cell中。
·CASE2:cells还未初始化(as为null),本分支计划初始化cells,在此之前开始执行cellsBusy加锁,并且要求cellsBusy加锁成功。
·CASE3:如果cellsBusy加锁失败,表示其他线程正在初始化cells,所以当前线程将值累加到base上。
不想研究了,有时间了在来研究
3.6 CAS在JDK中的广泛应用
3.6.1 CAS操作的弊端和规避措施
CAS操作的弊端主要有以下三点:
1.ABA问题
使用CAS操作内存数据时,数据发生过变化也能更新成功,如操作序列A==>B==>A时,最后一个CAS的预期数据A实际已经发生过更改,但也能更新成功,这就产生了ABA问题。
ABA问题的解决思路是使用版本号。在变量前面追加上版本号,每次变量更新的时候将版本号加1,那么操作序列A==>B==>A就会变成A1==>B2==>A3,如果将A1当作A3的预期数据,就会操作失败。
JDK提供了两个类AtomicStampedReference和AtomicMarkableReference来解决ABA问题。比较常用的是AtomicStampedReference类,该类的compareAndSet()方法的作用是首先检查当前引用是否等于预期引用,以及当前印戳是否等于预期印戳,如果全部相等,就以原子方式将引用和印戳的值一同设置为新的值。
2.只能保证一个共享变量之间的原子性操作
当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,CAS就无法保证操作的原子性。
一个比较简单的规避方法为:把多个共享变量合并成一个共享变量来操作。
JDK提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个AtomicReference实例后再进行CAS操作。比如有两个共享变量i=1、j=2,可以将二者合并成一个对象,然后用CAS来操作该合并对象的AtomicReference引用。
3.开销问题
自旋CAS如果长时间不成功(不成功就一直循环执行,直到成功),就会给CPU带来非常大的执行开销。
解决CAS恶性空自旋的有效方式之一是以空间换时间,较为常见的方案为:
(1)分散操作热点,使用LongAdder替代基础原子类AtomicLong,LongAdder将单个CAS热点(value值)分散到一个cells数组中。
(2)使用队列削峰,将发生CAS争用的线程加入一个队列中排队,降低CAS争用的激烈程度。JUC中非常重要的基础类AQS(抽象队列同步器)就是这么做的。
3.6.2 CAS操作在JDK中的应用
CAS在java.util.concurrent.atomic包中的原子类、Java AQS以及显式锁、CurrentHashMap等重要并发容器类的实现都有非常广泛的应用。
在java.util.concurrent.atomic包的原子类(如AtomicXXX)中都使用了CAS来保障对数字成员进行操作的原子性。
java.util.concurrent的大多数类(包括显式锁、并发容器)都是基于AQS和AtomicXXX来实现的,其中AQS通过CAS保障它内部双向队列头部、尾部操作的原子性。这些后面应该都有覆盖吧,,,
第4章 可见性与有序性的原理
原子性、可见性、有序性是并发编程所面临的三大问题。Java通过CAS操作解决了并发编程中的原子性问题,本章为大家介绍Java如何解决剩余的两个问题——可见性和有序性问题。
4.1 CPU物理缓存结构
由于CPU的运算速度比主存(物理内存)的存取速度快很多,为了提高处理速度,现代CPU不直接和主存进行通信,而是在CPU和主存之间设计了多层的Cache(高速缓存),越靠近CPU的高速缓存越快,容量也越小。
按照数据读取顺序和与CPU内核结合的紧密程度,CPU高速缓存有L1和L2高速缓存(即一级高速缓存和二级缓存高速),部分高端CPU还具有L3高速缓存(即三级高速缓存)。每一级高速缓存中所存储的数据都是下一级高速缓存的一部分,越靠近CPU的高速缓存读取越快,容量也越小。所以L1高速缓存容量很小,但存取速度最快,并且紧靠着使用它的CPU内核。L2容量大一些,存取速度也慢一些,并且仍然只能被一个单独的CPU核使用。L3在现代多核CPU中更普遍,容量更大、读取速度更慢些,能被同一个CPU芯片板上的所有CPU内核共享。
最后,系统还拥有一块主存(即主内存),由系统中的所有CPU共享。拥有L3高速缓存的CPU,CPU存取数据的命中率可达95%,也就是说只有不到5%的数据需要从主存中去存取。
image-20211109211130981
图4-1中的L1高速缓存和L2高速缓存都只能被一个单独的CPU内核使用,L3高速缓存可以被同一个CPU芯片上的所有CPU内核共享,而主存可以由系统中的所有CPU共享。
CPU通过高速缓存进行数据读取有以下优势:
(1)写缓冲区可以保证指令流水线持续运行,可以避免由于CPU停顿下来等待向内存写入数据而产生的延迟。
(2)通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,减少对内存总线的占用。
4.2 并发编程的三大问题
4.2.2 可见性问题
一个线程对共享变量的修改,另一个线程能够立刻可见,我们称该共享变量具备内存可见性。谈到内存可见性,要先引出JMM(Java Memory Model,Java内存模型)的概念。JMM规定,将所有的变量都存放在公共主存中,当线程使用变量时会把主存中的变量复制到自己的工作空间(或者叫私有内存)中,线程对变量的读写操作,是自己工作内存中的变量副本。
为什么Java局部变量、方法参数不存在内存可见性问题?在Java中,所有的局部变量、方法定义参数都不会在线程之间共享,所以也就不会有内存可见性问题。所有的Object实例、Class实例和数组元素都存储在JVM堆内存中,堆内存在线程之间共享,所以存在可见性问题。这个涉及到了jvm知识,后面会总结。
4.2.3 有序性问题
所谓程序的有序性,是指程序按照代码的先后顺序执行。如果程序执行的顺序与代码的先后顺序不同,并导致了错误的结果,即发生了有序性问题。
下面解释一下什么是指令重排序。一般来说,CPU为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行顺序同代码中的先后顺序一致,但是它会保证程序最终的执行结果和代码顺序执行的结果是一致的。
重排序也是单核时代非常优秀的优化手段,有足够多的措施保证其在单核下的正确性。在多核时代,如果工作线程之间不共享数据或仅共享不可变数据,重排序也是性能优化的利器。然而,如果工作线程之间共享了可变数据,由于两种重排序的结果都不是固定的,因此会导致工作线程似乎表现出了随机行为。
4.3 硬件层的MESI协议原理
由于每个线程可能会运行在不同的CPU内核中,因此每个线程拥有自己的高速缓存。同一份数据可能会被缓存到多个CPU内核中,在不同CPU内核中运行的线程看到同一个变量的缓存值就会不一样,就可能发生内存的可见性问题。
硬件层的MESI协议是一种用于解决内存的可见性问题的手段,接下来为大家介绍MESI协议的原理和具体内容。
4.3.1 总线锁和缓存锁
为了解决内存的可见性问题,CPU主要提供了两种解决办法:总线锁和缓存锁。
1.总线锁
操作系统提供了总线锁机制。前端总线(也叫CPU总线)是所有CPU与芯片组连接的主干道,负责CPU与外界所有部件的通信,包括高速缓存、内存、北桥,其控制总线向各个部件发送控制信号,通过地址总线发送地址信号指定其要访问的部件,通过数据总线实现双向传输。
在CPU内核1要执行i++操作的时候,将在总线上发出一个LOCK#信号锁住缓存(具体来说是变量所在的缓存行),这样其他CPU内核就不能操作缓存了,从而阻塞其他CPU内核,使CPU内核1可以独享此共享内存。
每当CPU内核访问L3中的数据时,都会通过线程总线来进行读取。总线锁的意思是在线程总线中加入一把锁,例如,当不同的CPU内核访问同一个缓存行时,只允许一个CPU内核进行读取,如图4-5所示,a、b存储于L3高速缓存中,当CPU内核1对a进行访问时,会在总线上发送一个LOCK#信号,CPU内核2想对b进行查询,但是总线被锁住,得等CPU内核1访问完,CPU内核2才能访问b。
image-20211109213110066
总线锁的缺陷是:某一个CPU访问主存时,总线锁把CPU和主存的通信给锁住了,其他CPU不能操作其他主存地址的数据,使得效率低下,开销较大。
2.缓存锁
相比总线锁,缓存锁降低了锁的粒度。为了达到数据访问的一致,需要各个CPU在访问高速缓存时遵循一些协议,在存取数据时根据协议来操作,常见的协议有MSI、MESI、MOSI等。最常见的就是MESI协议。
就整体而言,缓存一致性机制就是当某CPU对高速缓存中的数据进行操作之后,通知其他CPU放弃存储在它们内部的缓存数据,或者从主存中重新读取。用MESI描述的原理图如图4-6所示。
image-20211109213302948
为了提高处理速度,CPU不直接和主存进行通信,而是先将系统主存的数据读到内部高速缓存(L1、L2或其他)后再进行操作,但操作完不知道何时会写入内存。如果对声明了volatile的变量进行写操作,JVM就会向CPU发送一条带lock前缀的指令,将这个变量所在缓存行的数据写回系统主存。
但是,即使写回系统主存,如果其他CPU高速缓存中的值还是旧的,再执行计算操作也会有问题。所以,在多CPU的系统中,为了保证各个CPU的高速缓存中数据的一致性,会实现缓存一致性协议,每个CPU通过嗅探在总线上传播的数据来检查自己的高速缓存中的值是否过期,当CPU发现自己缓存行对应的主存地址被修改时,就会将当前CPU的缓存行设置成无效状态,当CPU对这个数据执行修改操作时,会重新从系统主存中把数据读到CPU的高速缓存中。
因为高速缓存的内容是部分主存内容的副本,所以应该与主存内容保持一致。而CPU对高速缓存副本如何与主存内容保持一致有几种写入模式供选择,主要的写入模式有以下两种:
(1)Write-Through(直写)模式:在数据更新时,同时写入低一级的高速缓存和主存。此模式的优点是操作简单,因为所有的数据都会更新到主存,所以其他CPU读取主存时都是最新值。此模式的缺点是数据写入速度较慢,因为数据修改之后需要同时写入低一级的高速缓存和主存。
(2)Write-Back(回写)模式:数据的更新并不会立即反映到主存,而是只写入高速缓存。只在数据被替换出高速缓存或者变成共享(S)状态时,如果发现数据有变动,才会将最新的数据更新到主存。
4.3.2 MSI协议
缓存一致性协议的基础版本为MSI协议,也叫作写入失效协议。如果同时有多个CPU要写入,总线会进行串行化,同一时刻只会有一个CPU获得总线的访问权。比如CPU c1、c2对变量m进行读写,采用缓存回写模式
4.3.3 MESI协议及RFO请求
目前主流的缓存一致性协议为MESI写入失效协议,而MESI是MSI协议的扩展。在MESI协议中,每个缓存行(Cache Line)有4种状态,即M、E、S和I(全名是Modified、Exclusive、Shared和Invalid)
MESI协议是以缓存行的4种状态的首字母缩写来命名的。该协议要求在每个缓存行上维护两个状态位,使得每个数据位可能处于M、E、S和I这4种状态之一。
1.M:被修改(Modified)
该缓存行的数据只在本CPU的私有高速缓存中进行了缓存,而其他CPU中没有,是被修改过的(Dirty),即与主存中的数据不一致,且没有更新到内存中。该缓存行中的内存需要在未来的某个时间点(允许其他CPU读取主存中相应的数据之前)写回(Write Back)主存。当被写回主存之后,该缓存行的状态会变成独享状态。
简单来说,处于Modified状态的缓存行数据只在本CPU中有缓存,且其数据与内存中的数据不一致,数据被修改过。说白了就是自己修改了但是没有回写。
2.E:独享的(Exclusive)
该缓存行的数据只在本CPU的私有高速缓存中进行了缓存,而其他CPU中没有,缓存行的数据是未被修改过的(Clean),并且与主存中的数据一致。该状态下的缓存行在任何时刻被其他CPU读取之后,其状态将变成共享状态。在本CPU修改了缓存行中的数据后,该缓存行的状态可以变成Modified状态。
简单来说,处于Exclusive状态的缓存行数据只在本CPU中有缓存,且其数据与内存中一致,没有被修改过。
说白了就是M回写了后就会变成E,E被其他线程共享后就变成了s
3.S:共享的(Shared)
该缓存行的数据可能在本CPU以及其他CPU的私有高速缓存中进行了缓存,并且各CPU私有高速缓存中的数据与主存数据一致(Clean),当有一个CPU修改该缓存行时,其他CPU中该缓存行将被作废,变成无效状态。
简单来说,处于Shared状态的缓存行的数据在多个CPU中都有缓存,且与主存一致。
4.I:无效的(Invalid)
该缓存行是无效的,可能有其他CPU修改了该缓存行。
接下来,分阶段说明一下这4种状态是如何转换的:
(1)初始阶段:开始时,缓存行没有加载任何数据,所以它处于“I状态”。
(2)本地写(Local Write)阶段:如果CPU内核写数据到处于“I状态”的缓存行,缓存行的状态就变成“M状态”。
(3)本地读(Local Read)阶段:如果本地CPU读取处于“I状态”的缓存行,很明显此缓存没有数据给它。此时分两种情况:①其他CPU的高速缓存中也没有此行数据,那么从内存加载数据到此缓存行后,将它设成“E状态”,表示只有“我”有此行数据,其他CPU都没有;②其他CPU的高速缓存有此行数据,就将此缓存行的状态设为“S状态”(注意:处于“M状态”的缓存行,再由本地CPU写入/读出,状态是不会改变的)。
(4)远程读(Remote Read)阶段:假设我们有两个CPU c1和c2,如果c2需要读c1的缓存行内容,c1需要把它的缓存行内容通过主存控制器(Memory Controller)发送给c2,c2接收到后将相应的缓存行状态设为“S状态”。在设置之前,主存要从总线上得到这份数据并保存。
(5)远程写(Remote Write)阶段:其实确切地说不是远程写,而是c2得到c1的数据后,不是为了读,而是为了写。也算是本地写,只是c1也拥有这份数据的拷贝,这该怎么办呢?c2将发出一个RFO(Request For Owner)请求,说明它需要拥有这行数据的权限,其他CPU的相应缓存行设为“I状态”,除了它之外,谁也不能动这行数据。这就保证了数据的安全,但处理RFO请求以及设置“I状态”的过程将给写操作带来很大的性能消耗。
4.3.4 volatile的原理
前面介绍过,为了解决CPU访问主存时主存读写性能的短板,在CPU中增加了高速缓存,但这带来了可见性问题。而Java的volatile关键字可以保证共享变量的主存可见性,也就是将共享变量的改动值立即刷新回主存。在正常情况下,系统操作并不会校验共享变量的缓存一致性,只有当共享变量用volatile关键字修饰了,该变量所在的缓存行才被要求进行缓存一致性的校验。
由于共享变量var加了volatile关键字,因此在汇编指令中,操作var之前多出一个lock前缀指令lock addl,该lock前缀指令有三个功能。
(1)将当前CPU缓存行的数据立即写回系统内存在对volatile修饰的共享变量进行写操作时,其汇编指令前用lock前缀修饰。lock前缀指令使得在执行指令期间,CPU可以独占共享内存(即主存)。对共享内存的独占,老的CPU(如Intel 486)通过总线锁方式实现。由于总线锁开销比较大,因此新版CPU(如IA-32、Intel 64)通过缓存锁实现对共享内存的独占性访问,缓存锁(缓存一致性协议)会阻止两个CPU同时修改共享内存的数据。
(2)lock前缀指令会引起在其他CPU中缓存了该内存地址的数据无效写回操作时要经过总线传播数据,而每个CPU通过嗅探在总线上传播的数据来检查自己缓存的值是否过期,当CPU发现自己缓存行对应的内存地址被修改时,就会将当前CPU的缓存行设置为无效状态,当CPU要对这个值进行修改的时候,会强制重新从系统内存中把数据读到CPU缓存。
(3)lock前缀指令禁止指令重排lock前缀指令的最后一个作用是作为内存屏障(MemoryBarrier)使用,可以禁止指令重排序,从而避免多线程环境下程序出现乱序执行的现象。
4.4 有序性与内存屏障
有序性是与可见性完全不同的概念,虽然二者都是CPU不断迭代升级的产物。由于CPU技术不断发展,为了重复释放硬件的高性能,编译器、CPU会优化待执行的指令序列,包括调整某些指令的顺序执行。优化的结果,指令执行顺序会与代码顺序略有不同,可能会导致代码执行出现有序性问题。
内存屏障又称内存栅栏(Memory Fences),是一系列的CPU指令,它的作用主要是保证特定操作的执行顺序,保障并发执行的有序性。在编译器和CPU都进行指令的重排优化时,可以通过在指令间插入一个内存屏障指令,告诉编译器和CPU,禁止在内存屏障指令前(或后)执行指令重排序。
4.4.1 重排序
为了提高性能,编译器和CPU常常会对指令进行重排序。重排序主要分为两类:编译器重排序和CPU重排序,具体如图4-10所示。
image-20211109224954115
1.编译器重排序
编译器重排序指的是在代码编译阶段进行指令重排,不改变程序执行结果的情况下,为了提升效率,编译器对指令进行乱序(Out-of-Order)的编译。
例如,在代码中,A操作需要获取其他资源而进入等待的状态,而A操作后面的代码跟A操作没有数据依赖关系,如果编译器一直等待A操作完成再往下执行的话,效率要慢得多,所以可以先编译后面的代码,这样的乱序可以提升编译速度。
编译器为什么要重排序(Re-Order)呢?它的目的为:与其等待阻塞指令(如等待缓存刷入)完成,不如先去执行其他指令。与CPU乱序执行相比,编译器重排序能够完成更大范围、效果更好的乱序优化。
2.CPU重排序
流水线(Pipeline)和乱序执行(Out-of-Order Execution)是现代CPU基本都具有的特性。机器指令在流水线中经历取指令、译码、执行、访存、写回等操作。为了CPU的执行效率,流水线都是并行处理的,在不影响语义的情况下。处理次序(Process Ordering,机器指令在CPU实际执行时的顺序)和程序次序(Program Ordering,程序代码的逻辑执行顺序)是允许不一致的,只要满足As-if-Serial规则即可。显然,这里的不影响语义依旧只能保证指令间的显式因果关系,无法保证隐式因果关系,即无法保证语义上不相关但是在程序逻辑上相关的操作序列按序执行。
所谓“乱序”,仅仅是被称为“乱序”,实际上也遵循着一定规则:只要两个指令之间不存在“数据依赖”,就可以对这两个指令乱序。
CPU重排序包括两类:指令级重排序和内存系统重排序。
(1)指令级重排序。在不影响程序执行结果的情况下,CPU内核采用ILP(Instruction-Level Parallelism,指令级并行运算)技术来将多条指令重叠执行,主要是为了提升效率。如果指令之间不存在数据依赖性,CPU就可以改变语句的对应机器指令的执行顺序,叫作指令级重排序。
(2)内存系统重排序:对于现代的CPU来说,在CPU内核和主存之间都具备一个高速缓存,高速缓存的作用主要是减少CPU内核和主存的交互(CPU内核的处理速度要快得多),在CPU内核进行读操作时,如果缓存没有的话就从主存取,而对于写操作都是先写在缓存中,最后再一次性写入主存,原因是减少跟主存交互时CPU内核的短暂卡顿,从而提升性能。但是,内存系统重排序可能会导致一个问题——数据不一致。
内存系统重排序和指令级重排序不同,内存系统重排序为伪重排序,也就是说只是看起来像在乱序执行而已。
4.4.2 As-if-Serial规则
在单核CPU的场景下,当指令被重排序之后,如何保障运行的正确性呢?其实很简单,编译器和CPU都需要遵守As-if-Serial规则。
As-if-Serial规则的具体内容为:无论如何重排序,都必须保证代码在单线程下运行正确。
为了遵守As-if-Serial规则,编译器和CPU不会对存在数据依赖关系的操作进行重排序,因为这种重排序会改变执行结果。但是,如果指令之间不存在数据依赖关系,这些指令可能被编译器和CPU重排序。
4.4.3 硬件层面的内存屏障
多核情况下,所有的CPU操作都会涉及缓存一致性协议(MESI协议)校验,该协议用于保障内存可见性。但是,缓存一致性协议仅仅保障内存弱可见(高速缓存失效),没有保障共享变量的强可见,而且缓存一致性协议更不能禁止CPU重排序,也就是不能确保跨CPU指令的有序执行。
如何保障跨CPU指令重排序之后的程序结果正确呢?需要用到内存屏障。
1.硬件层的内存屏障定义
由于不同的物理CPU硬件所提供的内存屏障指令的差异非常大,因此JMM定义了自己的一套相对独立的内存屏障指令,用于屏蔽不同硬件的差异性。很多Java关键字(如volatile)在语义中包含JMM内存屏障指令,在不同的硬件平台上,这些JMM内存屏障指令会要求JVM为不同的平台生成相应的硬件层的内存屏障指令。
4.5 JMM详解
JMM(Java Memory Model,Java内存模型)并不像JVM内存结构一样是真实存在的运行实体,更多体现为一种规范和规则。
4.5.1 什么是Java内存模型
JMM最初由JSR-133(Java Memory Model and ThreadSpecification)文档描述,JMM定义了一组规则或规范,该规范定义了一个线程对共享变量写入时,如何确保对另一个线程是可见的。实际上,JMM提供了合理的禁用缓存以及禁止重排序的方法,所以其核心的价值在于解决可见性和有序性。
Java内存模型定义的两个概念:
(1)主存:主要存储的是Java实例对象,所有线程创建的实例对象都存放在主存中,无论该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括共享的类信息、常量、静态变量。由于是共享数据区域,因此多条线程对同一个变量进行访问可能会发现线程安全问题。
(2)工作内存:主要存储当前方法的所有本地变量信息(工作内存中存储着主存中的变量副本),每个线程只能访问自己的工作内存,即线程中的本地变量对其他线程是不可见的,即使两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括字节码行号指示器、相关Native方法的信息。注意,由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。
Java内存模型的规定如下:
(1)所有变量存储在主存中。
(2)每个线程都有自己的工作内存,且对变量的操作都是在工作内存中进行的。
(3)不同线程之间无法直接访问彼此工作内存中的变量,要想访问只能通过主存来传递。
JMM将所有的变量都存放在公共主存中,当线程使用变量时,会把公共主存中的变量复制到自己的工作内存(或者叫作私有内存)中,线程对变量的读写操作是自己的工作内存中的变量副本。因此,JMM模型也需要解决代码重排序和缓存可见性问题。JMM提供了一套自己的方案去禁用缓存以及禁止重排序来解决这些可见性和有序性问题。JMM提供的方案包括大家都很熟悉的volatile、synchronized、final等。JMM定义了一些内存操作的抽象指令集,然后将这些抽象指令包含到Java的volatile、synchronized等关键字的语义中,并要求JVM在实现这些关键字时必须具备其包含的JMM抽象指令的能力。
4.5.2 JMM与JVM物理内存的区别
JMM属于语言级别的内存模型,它确保了在不同的编译器和不同的CPU平台上为Java程序员提供一致的内存可见性保证和指令并发执行的有序性。 规定了线程访问java内存的方式,然后可以保证有序和可见。
JMM属于概念和规范维度的模型,是一个参考性质的模型。JVM模型定义了一个指令集、一个虚拟计算机架构和一个执行模型。具体的JVM实现需要遵循JVM的模型,它能够运行根据JVM模型指令集编写的代码,就像真机可以运行机器代码一样。
第五章JUC显式锁的原理与实战
与Java内置锁不同,JUC显式锁是一种非常灵活的、使用纯Java语言实现的锁,这种锁的使用非常灵活,可以进行无条件的、可轮询的、定时的、可中断的锁获取和释放操作。由于JUC锁加锁和解锁的方法都是通过Java API显式进行的,因此也叫显式锁。
5.1 显式锁
使用Java内置锁时,不需要通过Java代码显式地对同步对象的监视器进行抢占和释放,这些工作由JVM底层完成,而且任何一个Java对象都能作为一个内置锁使用,所以Java的对象锁使用起来非常方便。但是,Java内置锁的功能相对单一,不具备一些比较高级的锁功能
Java显式锁就是为了解决这些Java对象锁的功能问题、性能问题而生的。JDK 5版本引入了Lock接口,Lock是Java代码级别的锁。为了与Java对象锁相区分,Lock接口叫作显式锁接口,其对象实例叫作显式锁对象
5.1.1 显式锁Lock接口