Java并发编程面试二
Java 并发常见面试题总结(下)
线程池
为什么要用线程池?
池化技术想必大家已经屡见不鲜了,线程池、数据库连接池、Http 连接池等等都是对这个思想的应用。池化技术的思想主要是为了减少每次获取资源的消耗,提高对资源的利用率。
线程池提供了一种限制和管理资源(包括执行一个任务)的方式。 每个线程池还维护一些基本统计信息,例如已完成任务的数量。
这里借用《Java 并发编程的艺术》提到的来说一下使用线程池的好处:
- 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
- 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
实现 Runnable 接口和 Callable 接口的区别
Runnable
自 Java 1.0 以来一直存在,但Callable
仅在 Java 1.5 中引入,目的就是为了来处理Runnable
不支持的用例。Runnable 接口 不会返回结果或抛出检查异常,但是 Callable 接口 可以。所以,如果任务不需要返回结果或抛出异常推荐使用 Runnable 接口 ,这样代码看起来会更加简洁。
工具类 Executors
可以实现将 Runnable
对象转换成 Callable
对象。(Executors.callable(Runnable task)
或 Executors.callable(Runnable task, Object result)
)。
Runnable.java
1 |
|
Callable.java
1 |
|
执行 execute()方法和 submit()方法的区别是什么呢?
- execute()方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否;
- submit()方法用于提交需要返回值的任务。线程池会返回一个 Future 类型的对象,通过这个 Future 对象可以判断任务是否执行成功,并且可以通过
Future
的get()
方法来获取返回值,get()
方法会阻塞当前线程直到任务完成,而使用get(long timeout,TimeUnit unit)
方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完。
我们以 AbstractExecutorService 接口 中的一个 submit
方法为例子来看看源代码:
1 | public Future<?> submit(Runnable task) { |
上面方法调用的 newTaskFor
方法返回了一个 FutureTask
对象。
1 | protected <T> RunnableFuture<T> newTaskFor(Runnable runnable, T value) { |
我们再来看看execute()
方法:
1 | public void execute(Runnable command) { |
如何创建线程池
《阿里巴巴 Java 开发手册》中强制线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险
Executors 返回线程池对象的弊端如下:
- FixedThreadPool 和 SingleThreadExecutor : 允许请求的队列长度为 Integer.MAX_VALUE ,可能堆积大量的请求,从而导致 OOM。
- CachedThreadPool 和 ScheduledThreadPool : 允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。
方式一:通过构造方法实现
方式二:通过 Executor 框架的工具类 Executors 来实现
我们可以创建三种类型的 ThreadPoolExecutor:
- FixedThreadPool : 该方法返回一个固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。
- SingleThreadExecutor: 方法返回一个只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。
- CachedThreadPool: 该方法返回一个可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。
对应 Executors 工具类中的方法如图所示:
ThreadPoolExecutor 类分析
ThreadPoolExecutor
类中提供的四个构造方法。我们来看最长的那个,其余三个都是在这个构造方法的基础上产生(其他几个构造方法说白点都是给定某些默认参数的构造方法比如默认制定拒绝策略是什么),这里就不贴代码讲了,比较简单。
1 | /** |
下面这些对创建 非常重要,在后面使用线程池的过程中你一定会用到!所以,务必拿着小本本记清楚。
ThreadPoolExecutor
构造函数重要参数分析
ThreadPoolExecutor 3 个最重要的参数:
- corePoolSize : 核心线程数定义了最小可以同时运行的线程数量。
- maximumPoolSize : 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。
- workQueue: 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。
ThreadPoolExecutor
其他常见参数:
- keepAliveTime:当线程池中的线程数量大于
corePoolSize
的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了keepAliveTime
才会被回收销毁; - unit :
keepAliveTime
参数的时间单位。 - threadFactory :executor 创建新线程的时候会用到。
- handler :饱和策略。关于饱和策略下面单独介绍一下。
ThreadPoolExecutor
饱和策略
ThreadPoolExecutor 饱和策略定义:
如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时,ThreadPoolTaskExecutor
定义一些策略:
- ThreadPoolExecutor.AbortPolicy: 抛出
RejectedExecutionException
来拒绝新任务的处理。 - ThreadPoolExecutor.CallerRunsPolicy: 调用执行自己的线程运行任务,也就是直接在调用
execute
方法的线程中运行(run
)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。 - ThreadPoolExecutor.DiscardPolicy: 不处理新任务,直接丢弃掉。
- ThreadPoolExecutor.DiscardOldestPolicy: 此策略将丢弃最早的未处理的任务请求。
举个例子: Spring 通过 ThreadPoolTaskExecutor
或者我们直接通过 ThreadPoolExecutor
的构造函数创建线程池的时候,当我们不指定 RejectedExecutionHandler
饱和策略的话来配置线程池的时候默认使用的是 ThreadPoolExecutor.AbortPolicy
。在默认情况下,ThreadPoolExecutor
将抛出 RejectedExecutionException
来拒绝新来的任务 ,这代表你将丢失对这个任务的处理。 对于可伸缩的应用程序,建议使用 ThreadPoolExecutor.CallerRunsPolicy
。当最大池被填满时,此策略为我们提供可伸缩队列。(这个直接查看 ThreadPoolExecutor
的构造函数源码就可以看出,比较简单的原因,这里就不贴代码了)
一个简单的线程池 Demo
为了让大家更清楚上面的面试题中的一些概念,我写了一个简单的线程池 Demo。
首先创建一个 Runnable
接口的实现类(当然也可以是 Callable
接口,我们上面也说了两者的区别。)
MyRunnable.java
1 | import java.util.Date; |
编写测试程序,我们这里以阿里巴巴推荐的使用 ThreadPoolExecutor
构造函数自定义参数的方式来创建线程池。
ThreadPoolExecutorDemo.java
1 | import java.util.concurrent.ArrayBlockingQueue; |
可以看到我们上面的代码指定了:
corePoolSize
: 核心线程数为 5。maximumPoolSize
:最大线程数 10keepAliveTime
: 等待时间为 1L。unit
: 等待时间的单位为 TimeUnit.SECONDS。workQueue
:任务队列为ArrayBlockingQueue
,并且容量为 100;handler
:饱和策略为CallerRunsPolicy
。
Output:
1 | pool-1-thread-3 Start. Time = Sun Apr 12 11:14:37 CST 2020 |
线程池原理分析
承接 4.6 节,我们通过代码输出结果可以看出:线程池首先会先执行 5 个任务,然后这些任务有任务被执行完的话,就会去拿新的任务执行。 大家可以先通过上面讲解的内容,分析一下到底是咋回事?(自己独立思考一会)
现在,我们就分析上面的输出内容来简单分析一下线程池原理。
为了搞懂线程池的原理,我们需要首先分析一下 execute方法。 在 4.6 节中的 Demo 中我们使用 executor.execute(worker)
来提交一个任务到线程池中去,这个方法非常重要,下面我们来看看它的源码:
1 | // 存放线程池的运行状态 (runState) 和线程池内有效线程的数量 (workerCount) |
通过下图可以更好的对上面这 3 步做一个展示,下图是我为了省事直接从网上找到,原地址不明。
现在,让我们在回到 4.6 节我们写的 Demo, 现在是不是很容易就可以搞懂它的原理了呢?
没搞懂的话,也没关系,可以看看我的分析:
我们在代码中模拟了 10 个任务,我们配置的核心线程数为 5 、等待队列容量为 100 ,所以每次只可能存在 5 个任务同时执行,剩下的 5 个任务会被放到等待队列中去。当前的5个任务中如果有任务被执行完了,线程池就会去拿新的任务执行。
Atomic 原子类
介绍一下 Atomic 原子类
Atomic
翻译成中文是原子的意思。在化学上,我们知道原子是构成一般物质的最小单位,在化学反应中是不可分割的。在我们这里 Atomic 是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。
所以,所谓原子类说简单点就是具有原子/原子操作特征的类。
并发包 java.util.concurrent
的原子类都存放在java.util.concurrent.atomic
下,如下图所示。
JUC 包中的原子类是哪 4 类?
基本类型
使用原子的方式更新基本类型
AtomicInteger
:整型原子类AtomicLong
:长整型原子类AtomicBoolean
:布尔型原子类
数组类型
使用原子的方式更新数组里的某个元素
AtomicIntegerArray
:整型数组原子类AtomicLongArray
:长整型数组原子类AtomicReferenceArray
:引用类型数组原子类
引用类型
AtomicReference
:引用类型原子类AtomicStampedReference
:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。AtomicMarkableReference
:原子更新带有标记位的引用类型
对象的属性修改类型
AtomicIntegerFieldUpdater
:原子更新整型字段的更新器AtomicLongFieldUpdater
:原子更新长整型字段的更新器AtomicReferenceFieldUpdater
:原子更新引用类型字段的更新器
讲讲 AtomicInteger 的使用
AtomicInteger 类常用方法
1 | public final int get() //获取当前的值 |
AtomicInteger 类的使用示例
使用 AtomicInteger
之后,不用对 increment()
方法加锁也可以保证线程安全。
1 | class AtomicIntegerTest { |
能不能给我简单介绍一下 AtomicInteger 类的原理
AQS
AQS 介绍
AQS 的全称为(AbstractQueuedSynchronizer
),这个类在java.util.concurrent.locks
包下面。
AQS 是一个用来构建锁和同步器的框架,使用 AQS 能简单且高效地构造出大量应用广泛的同步器,比如我们提到的 ReentrantLock
,Semaphore
,其他的诸如 ReentrantReadWriteLock
,SynchronousQueue
,FutureTask
等等皆是基于 AQS 的。当然,我们自己也能利用 AQS 非常轻松容易地构造出符合我们自己需求的同步器。
AQS 原理分析
AQS 原理这部分参考了部分博客,在 6.2 节末尾放了链接。
在面试中被问到并发知识的时候,大多都会被问到“请你说一下自己对于 AQS 原理的理解”。下面给大家一个示例供大家参加,面试不是背题,大家一定要加入自己的思想,即使加入不了自己的思想也要保证自己能够通俗的讲出来而不是背出来。
下面大部分内容其实在 AQS 类注释上已经给出了,不过是英语看着比较吃力一点,感兴趣的话可以看看源码。
AQS 原理概览
AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁实现的,即将暂时获取不到锁的线程加入到队列中。
CLH(Craig,Landin and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS 是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点(Node)来实现锁的分配。
看个 AQS(AbstractQueuedSynchronizer)原理图:
AQS 使用一个 int 成员变量来表示同步状态,通过内置的 FIFO 队列来完成获取资源线程的排队工作。AQS 使用 CAS 对该同步状态进行原子操作实现对其值的修改。
1 | private volatile int state;//共享变量,使用volatile修饰保证线程可见性 |
状态信息通过 protected 类型的 getState,setState,compareAndSetState 进行操作
1 | //返回同步状态的当前值 |
AQS 对资源的共享方式
AQS 定义两种资源共享方式
Exclusive
(独占):只有一个线程能执行,如
1
ReentrantLock
。又可分为公平锁和非公平锁:
- 公平锁:按照线程在队列中的排队顺序,先到者先拿到锁
- 非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的
Share(共享):多个线程可同时执行,如
CountDownLatch
、Semaphore
、CyclicBarrier
、ReadWriteLock
我们都会在后面讲到。
ReentrantReadWriteLock
可以看成是组合式,因为 ReentrantReadWriteLock
也就是读写锁允许多个线程同时对某一资源进行读。
不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源 state 的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS 已经在顶层实现好了。
AQS 底层使用了模板方法模式
同步器的设计是基于模板方法模式的,如果需要自定义同步器一般的方式是这样(模板方法模式很经典的一个应用):
- 使用者继承
AbstractQueuedSynchronizer
并重写指定的方法。(这些重写方法很简单,无非是对于共享资源 state 的获取和释放) - 将 AQS 组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的方法。
这和我们以往通过实现接口的方式有很大区别,这是模板方法模式很经典的一个运用。
AQS 使用了模板方法模式,自定义同步器时需要重写下面几个 AQS 提供的钩子方法:
1 | protected boolean tryAcquire(int)//独占方式。尝试获取资源,成功则返回true,失败则返回false。 |
什么是钩子方法呢? 钩子方法是一种被声明在抽象类中的方法,它可以是空方法(由子类实现),也可以是默认实现的方法。模板设计模式通过钩子方法控制固定步骤的实现。
除了上面提到的钩子方法之外,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 原理和相关源码分析的文章:
- https://www.cnblogs.com/waterystone/p/4920797.html
- https://www.cnblogs.com/chengxiao/archive/2017/07/24/7141160.html
AQS 组件总结
- Semaphore(信号量)-允许多个线程同时访问:
synchronized
和ReentrantLock
都是一次只允许一个线程访问某个资源,Semaphore
(信号量)可以指定多个线程同时访问某个资源。 - CountDownLatch (倒计时器):
CountDownLatch
是一个同步工具类,用来协调多个线程之间的同步。这个工具通常用来控制线程等待,它可以让某一个线程等待直到倒计时结束,再开始执行。 - CyclicBarrier(循环栅栏):
CyclicBarrier
和CountDownLatch
非常类似,它也可以实现线程间的技术等待,但是它的功能比CountDownLatch
更加复杂和强大。主要应用场景和CountDownLatch
类似。CyclicBarrier
的字面意思是可循环使用(Cyclic
)的屏障(Barrier
)。它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。CyclicBarrier
默认的构造方法是CyclicBarrier(int parties)
,其参数表示屏障拦截的线程数量,每个线程调用await()
方法告诉CyclicBarrier
我已经到达了屏障,然后当前线程被阻塞。
用过 CountDownLatch 么?什么场景下用的?
CountDownLatch
的作用就是 允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕。之前在项目中,有一个使用多线程读取多个文件处理的场景,我用到了 CountDownLatch
。具体场景是下面这样的:
我们要读取处理 6 个文件,这 6 个任务都是没有执行顺序依赖的任务,但是我们需要返回给用户的时候将这几个文件的处理的结果进行统计整理。
为此我们定义了一个线程池和 count 为 6 的CountDownLatch
对象 。使用线程池处理读取任务,每一个线程处理完之后就将 count-1,调用CountDownLatch
对象的 await()
方法,直到所有文件读取完之后,才会接着执行后面的逻辑。
伪代码是下面这样的:
1 | public class CountDownLatchExample1 { |
有没有可以改进的地方呢?
可以使用 CompletableFuture
类来改进!Java8 的 CompletableFuture
提供了很多对多线程友好的方法,使用它可以很方便地为我们编写多线程程序,什么异步、串行、并行或者等待所有线程执行完任务什么的都非常方便。
1 | CompletableFuture<Void> task1 = |
上面的代码还可以继续优化,当任务过多的时候,把每一个 task 都列出来不太现实,可以考虑通过循环来添加任务。
1 | //文件夹位置 |
参考
- 《深入理解 Java 虚拟机》
- 《实战 Java 高并发程序设计》
- Java并发之AQS详解:https://www.cnblogs.com/waterystone/p/4920797.html
- Java并发包基石-AQS详解:https://www.cnblogs.com/chengxiao/archive/2017/07/24/7141160.html
JMM(Java 内存模型)详解
从 CPU 缓存模型说起
为什么要弄一个 CPU 高速缓存呢? 类比我们开发网站后台系统使用的缓存(比如 Redis)是为了解决程序处理速度和访问常规关系型数据库速度不对等的问题。 CPU 缓存则是为了解决 CPU 处理速度和内存处理速度不对等的问题。
我们甚至可以把 内存可以看作外存的高速缓存,程序运行的时候我们把外存的数据复制到内存,由于内存的处理速度远远高于外存,这样提高了处理速度。
总结:CPU Cache 缓存的是内存数据用于解决 CPU 处理速度和内存不匹配的问题,内存缓存的是硬盘数据用于解决硬盘访问速度过慢的问题。
为了更好地理解,我画了一个简单的 CPU Cache 示意图如下(实际上,现代的 CPU Cache 通常分为三层,分别叫 L1,L2,L3 Cache):
CPU Cache 的工作方式: 先复制一份数据到 CPU Cache 中,当 CPU 需要用到的时候就可以直接从 CPU Cache 中读取数据,当运算完成后,再将运算得到的数据写回 Main Memory 中。但是,这样存在 内存缓存不一致性的问题 !比如我执行一个 i++ 操作的话,如果两个线程同时执行的话,假设两个线程从 CPU Cache 中读取的 i=1,两个线程做了 1++ 运算完之后再写回 Main Memory 之后 i=2,而正确结果应该是 i=3。
CPU 为了解决内存缓存不一致性问题可以通过制定缓存一致协议(比如 MESI 协议open in new window)或者其他手段来解决。 这个缓存缓存一致性协议指的是在 CPU 高速缓存与主内存交互的时候需要准守的原则和规范。不同的 CPU 中,使用的缓存一致性协议通常也会有所不同。
我们的程序运行在操作系统之上,操作系统屏蔽了底层硬件的操作细节,将各种硬件资源虚拟化。于是,操作系统也就同样需要解决内存缓存不一致性问题。
操作系统通过 内存模型(Memory Model) 定义一系列规范来解决这个问题。无论是 Windows 系统,还是 Linux 系统,它们都有特定的内存模型。
指令重排序
说完了 CPU 缓存模型,我们再来看看另外一个比较重要的概念 指令重排序 。
为了提升执行速度/性能,计算机在执行程序代码的时候,会对指令进行重排序。
什么是指令重排序? 简单来说就是系统在执行代码的时候并不一定是按照你写的代码的顺序依次执行。
常见的指令重排序有下面 2 种情况:
- 编译器优化重排 :编译器(包括 JVM、JIT 编译器等)在不改变单线程程序语义的前提下,重新安排语句的执行顺序。
- 指令并行重排 :现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
另外,内存系统也会有“重排序”,但有不是真正意义上的重排序。在 JMM 里表现为主存和本地内存的内容可能不一致,进而导致程序在多线程下执行可能出现问题。
Java 源代码会经历 编译器优化重排 —> 指令并行重排 —> 内存系统重排 的过程,最终才变成操作系统可执行的指令序列。
指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。
编译器和处理器的指令重排序的处理方式不一样。对于编译器,通过禁止特定类型的编译器的当时来禁止重排序。对于处理器,通过插入内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)的方式来禁止特定类型的处理器重排序。指令并行重排和内存系统重排都属于是处理器级别的指令重排序。
内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种 CPU 指令,用来禁止处理器指令发生重排序(像屏障一样),从而保障指令执行的有序性。另外,为了达到屏障的效果,它也会使处理器写入、读取值之前,将主内存的值写入高速缓存,清空无效队列,从而保障变量的可见性。
JMM(Java Memory Model)
什么是 JMM?为什么需要 JMM?
Java 是最早尝试提供内存模型的编程语言。由于早期内存模型存在一些缺陷(比如非常容易削弱编译器的优化能力),从 Java5 开始,Java 开始使用新的内存模型 《JSR-133:Java Memory Model and Thread Specification》open in new window 。
一般来说,编程语言也可以直接复用操作系统层面的内存模型。不过,不同的操作系统内存模型不同。如果直接复用操作系统层面的内存模型,就可能会导致同样一套代码换了一个操作系统就无法执行了。Java 语言是跨平台的,它需要自己提供一套内存模型以屏蔽系统差异。
这只是 JMM 存在的其中一个原因。实际上,对于 Java 来说,你可以把 JMM 看作是 Java 定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,其还规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。
为什么要遵守这些并发相关的原则和规范呢? 这是因为并发编程下,像 CPU 多级缓存和指令重排这类设计可能会导致程序运行出现一些问题。就比如说我们上面提到的指令重排序就可能会让多线程程序的执行出现问题,为此,JMM 抽象了 happens-before 原则(后文会详细介绍到)来解决这个指令重排序问题。
JMM 说白了就是定义了一些规范来解决这些问题,开发发者可以利用这些规范更方便地开发多线程程序。对于 Java 开发者说,你不需要了解底层原理,直接使用并发相关的一些关键字和类(比如 volatile
、synchronized
、各种 Lock
)即可开发出并发安全的程序。
JMM 是如何抽象线程和主内存之间的关系?
Java 内存模型(JMM) 抽象了线程和主内存之间的关系,就比如说线程之间的共享变量必须存储在主内存中。
在 JDK1.2 之前,Java 的内存模型实现总是从 主存 (即共享内存)读取变量,是不需要进行特别的注意的。而在当前的 Java 内存模型下,线程可以把变量保存 本地内存 (比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。
这和我们上面讲到的 CPU 缓存模型非常相似。
什么是主内存?什么是本地内存?
- 主内存 :所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量)
- 本地内存 :每个线程都有一个私有的本地内存来存储共享变量的副本,并且,每个线程只能访问自己的本地内存,无法访问其他线程的本地内存。本地内存是 JMM 抽象出来的一个概念,存储了主内存中的共享变量副本。
Java 内存模型的抽象示意图如下:
从上图来看,线程 1 与线程 2 之间如果要进行通信的话,必须要经历下面 2 个步骤:
- 线程 1 把本地内存中修改过的共享变量副本的值同步到主内存中去。
- 线程 2 到主存中读取对应的共享变量的值。
也就是说,JMM 为共享变量提供了可见性的保障。
不过,多线程下,对主内存中的一个共享变量进行操作有可能诱发线程安全问题。举个例子:
- 线程 1 和线程 2 分别对同一个共享变量进行操作,一个执行修改,一个执行读取。
- 线程 2 读取到的是线程 1 修改之前的值还是修改后的值并不确定,都有可能,因为线程 1 和线程 2 都是先将共享变量从主内存拷贝到对应线程的工作内存中。
关于主内存与工作内存直接的具体交互协议,即一个变量如何从主内存拷贝到工作内存,如何从工作内存同步到主内存之间的实现细节,Java 内存模型定义来以下八种同步操作(了解即可,无需死记硬背):
- 锁定(lock): 作用于主内存中的变量,将他标记为一个线程独享变量。
- 解锁(unlock): 作用于主内存中的变量,解除变量的锁定状态,被解除锁定状态的变量才能被其他线程锁定。
- read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。
- **load(载入)**:把 read 操作从主内存中得到的变量值放入工作内存的变量的副本中。
- **use(使用)**:把工作内存中的一个变量的值传给执行引擎,每当虚拟机遇到一个使用到变量的指令时都会使用该指令。
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
- store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用。
- write(写入):作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中。
除了这 8 种同步操作之外,还规定了下面这些同步规则来保证这些同步操作的正确执行(了解即可,无需死记硬背):
- 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存中。
- 一个新的变量只能在主内存中 “诞生”,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量,换句话说就是对一个变量实施 use 和 store 操作之前,必须先执行过了 assign 和 load 操作。
- 一个变量在同一个时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。
- 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作初始化变量的值。
- 如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作,也不允许去 unlock 一个被其他线程锁定住的变量。
- ……
Java 内存区域和 JMM 有何区别?
这是一个比较常见的问题,很多初学者非常容易搞混。 Java 内存区域和内存模型是完全不一样的两个东西 :
- JVM 内存结构和 Java 虚拟机的运行时区域相关,定义了 JVM 在运行时如何分区存储程序数据,就比如说堆主要用于存放对象实例。
- Java 内存模型和 Java 的并发编程相关,抽象了线程和主内存之间的关系就比如说线程之间的共享变量必须存储在主内存中,规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。
happens-before 原则是什么?
happens-before 这个概念最早诞生于 Leslie Lamport 于 1978 年发表的论文《Time,Clocks and the Ordering of Events in a Distributed System》open in new window。在这篇论文中,Leslie Lamport 提出了逻辑时钟open in new window的概念,这也成了第一个逻辑时钟算法 。在分布式环境中,通过一系列规则来定义逻辑时钟的变化,从而能通过逻辑时钟来对分布式系统中的事件的先后顺序进行判断。逻辑时钟并不度量时间本身,仅区分事件发生的前后顺序,其本质就是定义了一种 happens-before 关系。
上面提到的 happens-before 这个概念诞生的背景并不是重点,简单了解即可。
JSR 133 引入了 happens-before 这个概念来描述两个操作之间的内存可见性。
为什么需要 happens-before 原则? happens-before 原则的诞生是为了程序员和编译器、处理器之间的平衡。程序员追求的是易于理解和编程的强内存模型,遵守既定规则编码即可。编译器和处理器追求的是较少约束的弱内存模型,让它们尽己所能地去优化性能,让性能最大化。happens-before 原则的设计思想其实非常简单:
- 为了对编译器和处理器的约束尽可能少,只要不改变程序的执行结果(单线程程序和正确执行的多线程程序),编译器和处理器怎么进行重排序优化都行。
- 对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序。
下面这张是 《Java 并发编程的艺术》这本书中的一张 JMM 设计思想的示意图,非常清晰。
了解了 happens-before 原则的设计思想,我们再来看看 JSR-133 对 happens-before 原则的定义:
- 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,并且第一个操作的执行顺序排在第二个操作之前。
- 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么 JMM 也允许这样的重排序。
我们看下面这段代码:
1 | int userNum = getUserNum(); // 1 |
- 1 happens-before 2
- 2 happens-before 3
- 1 happens-before 3
虽然 1 happens-before 2,但对 1 和 2 进行重排序不会影响代码的执行结果,所以 JMM 是允许编译器和处理器执行这种重排序的。但 1 和 2 必须是在 3 执行之前,也就是说 1,2 happens-before 3 。
happens-before 原则表达的意义其实并不是一个操作发生在另外一个操作的前面,虽然这从程序员的角度上来说也并无大碍。更准确地来说,它更想表达的意义是前一个操作的结果对于后一个操作是可见的,无论这两个操作是否在同一个线程里。
举个例子:操作 1 happens-before 操作 2,即使操作 1 和操作 2 不在同一个线程内,JMM 也会保证操作 1 的结果对操作 2 是可见的。
happens-before 常见规则有哪些?谈谈你的理解?
happens-before 的规则就 8 条,说多不多,重点了解下面列举的 5 条即可。全记是不可能的,很快就忘记了,意义不大,随时查阅即可。
- 程序顺序规则 :一个线程内,按照代码顺序,书写在前面的操作 happens-before 于书写在后面的操作;
- 解锁规则 :解锁 happens-before 于加锁;
- volatile 变量规则 :对一个 volatile 变量的写操作 happens-before 于后面对这个 volatile 变量的读操作。说白了就是对 volatile 变量的写操作的结果对于发生于其后的任何操作都是可见的。
- 传递规则 :如果 A happens-before B,且 B happens-before C,那么 A happens-before C;
- 线程启动规则 :Thread 对象的
start()
方法 happens-before 于此线程的每一个动作。
如果两个操作不满足上述任意一个 happens-before 规则,那么这两个操作就没有顺序的保障,JVM 可以对这两个操作进行重排序。
happens-before 和 JMM 什么关系?
happens-before 与 JMM 的关系用《Java 并发编程的艺术》这本书中的一张图就可以非常好的解释清楚。
再看并发编程三个重要特性
原子性
一次操作或者多次操作,要么所有的操作全部都得到执行并且不会受到任何因素的干扰而中断,要么都不执行。
在 Java 中,可以借助synchronized
、各种 Lock
以及各种原子类实现原子性。
synchronized
和各种 Lock
可以保证任一时刻只有一个线程访问该代码块,因此可以保障原子性。各种原子类是利用 CAS (compare and swap) 操作(可能也会用到 volatile
或者final
关键字)来保证原子操作。
可见性
当一个线程对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。
在 Java 中,可以借助synchronized
、volatile
以及各种 Lock
实现可见性。
如果我们将变量声明为 volatile
,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。
有序性
由于指令重排序问题,代码的执行顺序未必就是编写代码时候的顺序。
我们上面讲重排序的时候也提到过:
指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。
在 Java 中,volatile
关键字可以禁止指令进行重排序优化。
总结
- Java 是最早尝试提供内存模型的语言,其主要目的是为了简化多线程编程,增强程序可移植性的。
- CPU 可以通过制定缓存一致协议(比如 MESI 协议open in new window)来解决内存缓存不一致性问题。
- 为了提升执行速度/性能,计算机在执行程序代码的时候,会对指令进行重排序。 简单来说就是系统在执行代码的时候并不一定是按照你写的代码的顺序依次执行。指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。
- 你可以把 JMM 看作是 Java 定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,其还规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。
- JSR 133 引入了 happens-before 这个概念来描述两个操作之间的内存可见性。
参考
- 《Java 并发编程的艺术》第三章 Java 内存模型
- 《深入浅出 Java 多线程》:http://concurrent.redspider.group/RedSpider.html
- Java 内存访问重排序的研究:https://tech.meituan.com/2014/09/23/java-memory-reordering.html
- 嘿,同学,你要的 Java 内存模型 (JMM) 来了:https://xie.infoq.cn/article/739920a92d0d27e2053174ef2
- JSR 133 (Java Memory Model) FAQ:https://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html