线程池是核心,包含了大部分知识,这是重点!!!
前言
1 项目中有用到多线程吗?怎么用的
2 说一说你对并发的理解,以及开发中需要注意的地方
3 多线程缺省同步锁的知识
大家都知道,在多线程开发中,为了解决并发问题,主要是通过使用synchronized来加互斥锁进行同步控制。但是在某些情况中,JVM已经隐含地为您执行了同步,这些情况下就不用自己再来进行同步控制了。这些情况包括:
1.由静态初始化器(在静态字段上或static{}块中的初始化器)初始化数据时
2.访问final字段时
3.在创建线程之前创建对象时
4.线程可以看见它将要处理的对象时
一、基础
1 为什么要使用并发编程
提升系统并发能力和性能
现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。也方便进行业务拆分,将不同步骤拆分为并发执行,提升应用性能。
充分利用多核CPU的计算能力
通过并发编程的形式可以将多核CPU的计算能力发挥到极致,性能得到提升。
2 进程与线程的区别
线程具有许多传统进程所具有的特征,故又称为轻型进程(Light—Weight Process)或进程元;而把传统的进程称为重型进程(Heavy—Weight Process),它相当于只有一个线程的任务。在引入了线程的操作系统中,通常一个进程都有若干个线程,至少包含一个线程。
根本区别: 进程是操作系统资源分配的基本单位,而线程是处理器任务调度和执行的基本单位。
资源开销: 每个进程都有独立的代码和数据空间(程序上下文),程序之间的切换会有较大的开销;线程可以看做轻量级的进程,同一类线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器(PC),线程之间切换的开销小。
包含关系: 如果一个进程内有多个线程,则执行过程不是一条线的,而是多条线(线程)共同完成的;线程是进程的一部分,所以线程也被称为轻权进程或者轻量级进程。
内存分配: 同一进程的线程共享本进程的地址空间和资源,而进程之间的地址空间和资源是相互独立的
影响关系: 一个进程崩溃后,在保护模式下不会对其他进程产生影响,但是一个线程崩溃整个进程都死掉。所以多进程要比多线程健壮。
执行过程: 每个独立的进程有程序运行的入口、顺序执行序列和程序出口。但是线程不能独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制,两者均可并发执行。
2 并发编程有什么缺点
2.1 频繁的上下文切换损耗性能
时间片是CPU分配给各个线程的时间,因为时间非常短,所以CPU不断通过切换线程,让我们觉得多个线程是同时执行的,时间片一般是几十毫秒。而每次切换时,需要保存当前的状态起来,以便能够进行恢复先前状态,而这个切换时非常损耗性能,过于频繁反而无法发挥出多线程编程的优势。通常减少上下文切换可以采用无锁并发编程,CAS算法,使用最少的线程和使用协程。
无锁并发编程:可以参照concurrentHashMap锁分段的思想,不同的线程处理不同段的数据,这样在多线程竞争的条件下,可以减少上下文切换的时间。
CAS算法,利用Atomic下使用CAS算法来更新数据,使用了乐观锁,可以有效的减少一部分不必要的锁竞争带来的上下文切换
使用最少线程:避免创建不需要的线程,比如任务很少,但是创建了很多的线程,这样会造成大量的线程都处于等待状态
协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换
由于上下文切换也是个相对比较耗时的操作,所以在"java并发编程的艺术"一书中有过一个实验,并发累加未必会比串行累加速度要快。
2.2 线程安全问题
多线程编程中最难以把握的就是临界区线程安全问题,稍微不注意就会出现死锁的情况,一旦产生死锁就会造成系统功能不可用
什么是线程安全
线程安全是指某个方法或某段代码,在多线程中能够正确的执行,和单线程运行的结果一致,不会出现数据不一致或数据污染的情况,我们把这样的程序称之为线程安全的,反之则为非线程安全的。
线程安全问题都是由全局变量及静态变量引起的。
若每个线程中对全局变量、静态变量只有读操作,而无写操作,一般来说,这个全局变量是线程安全的;若有多个线程同时执行写操作,一般都需要考虑线程同步,否则就可能影响线程安全。
2.3 内存泄漏
线程需要占用内存,线程越多占用内存也越多。
如果线程池中有核心线程,且是核心线程不会在空闲时被回收(默认allowCoreThreadTimeOut=false),那么就会导致核心线程一直阻塞在获取任务上,该核心线程不会结束,GC也不会回收,很可能会导致内存溢出。
2.4 并发编程为什么会导致编写程序难度加大
并发编程相对于单线程编程需要通过额外的代码编写,并需要考虑线程安全、上下文切换消耗性能、内存泄漏、死锁问题。
3 并发编程三要素是什么?
并发编程三要素(线程的安全性问题体现所在):
原子性: 原子,即一个不可再被分割的颗粒。原子性指的是一个或多个操作要么全部执行成功要么全部执行失败。
可见性: 一个线程对共享变量的修改,另一个线程能够立刻看到。(synchronized,volatile)
有序性: 程序执行的顺序按照代码的先后顺序执行。(处理器可能会对指令进行重排序)
4 出现线程安全问题的原因是什么?
根本原因:线程安全问题都是由全局变量及静态变量引起的
表现在以下三个方面:
线程切换带来的原子性问题
缓存导致的可见性问题
编译优化带来的有序性问题
5 如何解决线程安全问题?
- 原子性问题解决方案:JDK Atomic开头的原子类、synchronized、LOCK
- 可见性问题解决方案:synchronized、volatile、LOCK,可以解决可见性问题
- 有序性问题解决方案:Happens-Before 规则
空间换时间解决方案:ThreadLocal
6 守护线程和用户线程有什么区别呢?
- 用户 (User) 线程: 运行在前台,执行具体的任务,如程序的主线程、连接网络的子线程等都是用户线程
- 守护 (Daemon) 线程: 运行在后台,为其他前台线程服务。也可以说守护线程是 JVM 中非守护线程的 “佣人”。一旦所有用户线程都结束运行,守护线程会随 JVM 一起结束工作
7 主线程启动子线程后,主线程执行结束后子线程还能运行吗?
- 正常情况下,主线程启动了子线程,主线程、子线程各自执行,彼此不受影响,JVM会等待两个线程结束才会退出。
- 子线程设置为守护线程后,主线程执行结束时,子线程立即结束
3 . 子线程设置了 Thread.join(),子线程执行结束后,主线程才会结束
这里的这个示例并不能说明join()的用法,应该在主线程启动一个线程A,在线程A中再启动线程B,在线程A中设置B.join(),这样才能体现join的用法,不然会认为这个示例和第一个示例没啥区别,用不用join()都一样。
8 并行和并发有什么区别?
并发: 多个任务在同一个 CPU 核上,按细分的时间片轮流(交替)执行,从逻辑上来看那些任务是同时执行。
并行: 单位时间内,多个处理器或多核处理器同时处理多个任务,是真正意义上的“同时进行”。
串行: 有n个任务,由一个线程按顺序执行。由于任务、方法都在一个线程执行所以不存在线程不安全情况,也就不存在临界区的问题。
9 什么是线程死锁
两个线程或两个以上线程都在等待对方执行完毕才能继续往下执行的时候就发生了死锁。结果就是这些线程都陷入了无限的等待中。
死锁是指两个或两个以上的进程(线程)在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程(线程)称为死锁进程(线程)。
10 形成死锁的必要条件是什么
- 互斥条件: 线程(进程)对于所分配到的资源具有排它性,即一个资源只能被一个线程(进程)占用,直到被该线程(进程)释放
- 请求与保持条件: 一个线程(进程)因请求被占用资源而发生阻塞时,对已获得的资源保持不放。
- 不剥夺条件: 线程(进程)已获得的资源在末使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
- 环路等待条件: 当发生死锁时,所等待的线程(进程)必定会形成一个环路(类似于死循环),造成永久阻塞。指在发生死锁时,必然存在一个(线程 — 资源)的环形链,即线程集合 {T0,T1,T2,…,Tn} 中的 T0 正在等待一个 T1 占用的资源,T1正在等待 T2 占用的资源,······Tn 正在等待已被 T0 占用的资源。
11 如何避免线程死锁
我们只要破坏产生死锁的四个必要条件中的其中一个就可以了。
而在操作系统中,互斥条件和不可剥夺条件是系统规定的必须存在的,这也没办法人为更改,而且这两个条件很明显是一个标准的程序应该所具备的特性。所以目前只有请求并持有和环路等待条件是可以被破坏的。
1 破坏互斥条件
这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问)。
2 破坏请求与保持条件
一次性申请所有的资源。
3 破坏不剥夺条件
占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
4 破坏循环等待条件
靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。比如都先申请资源a,再区申请b。而不是一个先申请a,一个先申请b。
12 线程的 run()和 start()有什么区别?
每个线程都是通过某个特定Thread对象所对应的方法run()来完成其操作的,run()方法称为线程体。通过调用Thread类的start()方法来启动一个线程。
区别:
- start() 方法用于启动独立线程来执行run(),run() 方法用于执行线程的运行时代码,并不会启动独立线程执行。
- run() 可以重复调用,而 start() 只能调用一次。
start()方法来启动一个线程,真正实现了多线程运行。调用start()方法无需等待run方法体代码执行完毕,可以直接继续执行其他的代码; 此时线程是处于就绪状态,并没有运行。 然后通过此Thread类调用方法run()来完成其运行状态, run()方法运行结束, 此线程终止。然后CPU再调度其它线程。
run()方法是在本线程里的,只是线程里的一个函数,而不是多线程的。 如果直接调用run(),其实就相当于是调用了一个普通函数而已,直接待用run()方法必须等待run()方法执行完毕才能执行下面的代码,所以执行路径还是只有一条,根本就没有线程的特征,所以在多线程执行时要使用start()方法而不是run()方法。
13 为什么我们调用 start() 方法时会执行 run() 方法,为什么我们不能直接调用 run() 方法?
这是另一个非常经典的 java 多线程面试问题,而且在面试中会经常被问到。很简单,但是很多人都会答不上来!
new 一个 Thread,线程进入了新建状态。调用 start() 方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。
而直接执行 run() 方法,会把 run 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。
总结: 调用 start 方法方可启动线程并使线程进入就绪状态,而 run 方法只是 thread 的一个普通方法调用,还是在主线程里执行。
start()到run()的整个过程如下图:
12 创建线程的方式
- 继承 Thread 类: 调用子类实例的start()方法来启动线程
- 实现 Runnable 接口: 以子类实例构建Thead对象后调用Thread的start()方法启动线程,本身无法自启。
- 实现 Callable 接口: 以子类实例为参数创建FutureTask对象后再以此为参数构建Thead对象后调用Thread的start()方法启动线程,或者以子类实例用submit()提交到线程池中直接执行。
- 使用 Executors 工具类创建线程池: 提供了一系列工厂方法用于创先线程池,主要有newFixedThreadPool,newCachedThreadPool,newSingleThreadExecutor,newScheduledThreadPool,后续详解
13 callable详解
13.1 callable介绍
它是一个接口,位于juc包下,代表一个异步的任务,在多线程环境下,使用callable更明智,因为有些时候,我们也许需要获取方法的执行结果,或者得到异常信息,那么使用callable接口要比使用runnable接口来的方便的多。
callable接口里面只有一个方法是call()方法,它能够自定义返回值类型,也可以抛出异常,以便程序员看到发生了什么错误。
@FunctionalInterface
public interface Callable<V> {
/**
* Computes a result, or throws an exception if unable to do so.
*
* @return computed result
* @throws Exception if unable to compute a result
*/
V call() throws Exception;
}
13.2 callable使用
callable一定要和FutureTask配合使用,无论是线程池执行callable还是其它,都会被包装为FutureTask再运行。线程池也是通过new Thread()来执行任务的。+++
示例如下:
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
public class CallableTest {
public static void main(String[] args) throws InterruptedException, ExecutionException {
List<String> datas = new ArrayList();
datas.add("data1");
datas.add("data2");
datas.add("data3");
long start = System.currentTimeMillis();
//模拟处理3条数据
for(int i=0; i<3; i++){
MyCallable myCallable = new MyCallable(datas.get(i));
// 1. 包装为FutureTask后,再启动线程执行
FutureTask<String> futureTask = new FutureTask<String>(myCallable);
new Thread(futureTask).start();
// 2.启动线程来直接执行
//new Thread(myCallable).start(); // 不能Thread直接启动,会报错
// 3. 线程池执行callable,submit会把callable包装为FutureTask
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<String> result = executor.submit(myCallable);
String s = result.get();
System.out.println("========获取结果:"+s);
}
System.out.println("处理数据消耗:"+(System.currentTimeMillis()-start));
}
}
class MyCallable implements Callable<String> {
private String data;
public MyCallable(String data) {
this.data = data;
}
@Override
public String call() throws Exception {
System.out.println("当前线程名称:"+Thread.currentThread().getName());
System.out.println("当前数据名称:"+data);
try {
System.out.println("开始处理:"+data);
Thread.sleep(1000);
System.out.println("处理结束:"+data);
} catch (InterruptedException e) {
e.printStackTrace();
}
return data;
}
}
13.3 callbale实现原理
13.3.1 Callable是如何被线程调度的
首先,线程的启动一定是通过Thread对象的start()方法,在Thread类的构造方法中,可以传入一个Runnable作为参数,线程启动后,操作系统调度线程回调Runnable中的run()方法,达到异步调用的效果。
在FutureTask类的顶层接口中实现了Runnable接口,线程获取到CPU资源后,就会去回调FutureTask中的run()方法,所以只需要在run()方法中去调用call()就实现了Callable的调度。
下面的源码省略了部分代码,只保留了最核心的部分:
public class FutureTask<V> implements RunnableFuture<V> {
private volatile int state;
private static final int NEW = 0;
private static final int COMPLETING = 1;
private static final int NORMAL = 2;
private static final int EXCEPTIONAL = 3;
private static final int CANCELLED = 4;
private static final int INTERRUPTING = 5;
private static final int INTERRUPTED = 6;
public void run() {
Callable<V> c = callable;
if (c != null && state == NEW) {
V result;
boolean ran;
try {
// 发起Callable的调度,并获取返回值
result = c.call();
ran = true;
} catch (Throwable ex) {
result = null;
ran = false;
// 如果报错,就抛出异常
setException(ex);
}
if (ran)
// 设置返回值
set(result);
}
}
}
核心代码还是比较好理解的,调用成功就设置返回值,调用失败就设置异常,两个方法都是赋值给一个成员变量outcome。
public class FutureTask<V> implements RunnableFuture<V> {
private Object outcome;
protected void set(V v) {
// 将当前的FutureTask替换为完成状态
if (UNSAFE.compareAndSwapInt(this, stateOffset, NEW, COMPLETING)) {
// 保存返回值
outcome = v;
// 保存完成后替换为正常状态
UNSAFE.putOrderedInt(this, stateOffset, NORMAL); // final state
finishCompletion();
}
}
protected void setException(Throwable t) {
if (UNSAFE.compareAndSwapInt(this, stateOffset, NEW, COMPLETING)) {
outcome = t;
// 保存成功后替换成异常状态
UNSAFE.putOrderedInt(this, stateOffset, EXCEPTIONAL); // final state
finishCompletion();
}
}
}
13.3.2 返回值的获取
从上面的代码中可以看到,无论是正常的返回值还是异常的返回值都使用了同一个Object对象来接收,那么外层的父线程是如何接收到返回值的呢?
FutureTask还实现了一个Future接口,里面有一个get()方法可以用来获取子线程保存的返回值,get()的实现方法如下:
public V get() throws InterruptedException, ExecutionException {
int s = state;
// 小于完成状态表示子线程还在执行中,当前线程需要等待(阻塞的实现)
if (s <= COMPLETING)
s = awaitDone(false, 0L);
// 如果返回值正常就正常返回,如果是异常就抛出
return report(s);
}
上面的report(s)方法就是用来做返回值处理的,在返回结果时,需要等待子线程执行完毕并将返回值赋值给FutureTask的成员变量outcome,下面先看看返回值是如何返回的,再看阻塞方法。
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);
}
上面就是返回结果的方法非常直观,就不多做描述了,接下来看一下阻塞的实现。
13.3.3 阻塞的实现
父线程想要获取子线程的返回值,前提条件是子线程一定执行完毕了,如果在子线程执行完毕之前,父线程调用FutureTask的get()方法就会先进入阻塞,阻塞的实现和AQS类似,就是将父线程包装在一个Node节点中,然后将它挂起。
private int awaitDone(boolean timed, long nanos) throws InterruptedException {
// 等待队列
private volatile WaitNode waiters;
final long deadline = timed ? System.nanoTime() + nanos : 0L;
WaitNode q = null;
boolean queued = false;
// 自旋锁,这个锁里面有两个退出条件
for (;;) {
// 线程被中断,不再等待
if (Thread.interrupted()) {
removeWaiter(q);
throw new InterruptedException();
}
// 判断一下子线程现在是否执行完毕,如果已经执行完了就不需要阻塞了
// 退出条件1
int s = state;
if (s > COMPLETING) {
if (q != null)
q.thread = null;
return s;
}
// COMPLETING表示子线程已经执行完毕,正在给成员变量赋值,这个时候父线程让出CPU资源
else if (s == COMPLETING) // cannot time out yet
Thread.yield();
else if (q == null)
q = new WaitNode();
else if (!queued)
// 将新节点在等待队列中做头插入
queued = UNSAFE.compareAndSwapObject(this, waitersOffset, q.next = waiters, q);
// 如果设置了等待超时时间,将当前线程按超时时间挂起
else if (timed) {
nanos = deadline - System.nanoTime();
if (nanos <= 0L) {
removeWaiter(q);
// 自旋锁退出条件2
return state;
}
LockSupport.parkNanos(this, nanos);
}
// 没有配置超时时间就将当前现在直接挂起
else
LockSupport.park(this);
}
}
13.3.4 阻塞线程的唤醒
有阻塞必然有唤醒,没有等待时间的被挂起的线程是在哪里被唤醒的呢?
我们想一下,父线程的阻塞原因是子线程还没有执行完毕,所以在子线程执行完毕后就应该去把挂起的父线程唤醒。那唤醒的方法一定在run()方法中,在上面的两个保存返回值的set()方法中执行了finishCompletion(),父线程的唤醒就在这里面。
private void finishCompletion() {
// waiters如果等于null,就没有线程处于等待状态
for (WaitNode q; (q = waiters) != null;) {
if (UNSAFE.compareAndSwapObject(this, waitersOffset, q, null)) {
// 从等待队列头开始迭代,依次唤醒队列中的线程
for (;;) {
Thread t = q.thread;
if (t != null) {
q.thread = null;
LockSupport.unpark(t);
}
// 队列迭代逻辑,并将已经唤醒的节点断开引用,从而
WaitNode next = q.next;
if (next == null)
break;
q.next = null;
q = next;
}
break;
}
}
done();
callable = null;
}
13.5 Callble执行流程图
14 runnable 和 callable 有什么区别?
14.1 相同点
- 都是接口
- 都可以编写多线程程序
- 都是采用Thread.start()或者提交给线程池启动线程
14.2 区别
Runnable 接口 run 方法无返回值;Callable 接口 call 方法有返回值,是个泛型,和Future、FutureTask配合可以用来获取异步执行的结果。
Runnable 接口 run 方法只能抛出运行时异常,且无法捕获处理;Callable 接口 call 方法允许抛出异常,可以获取异常信息
注:Callalbe接口支持返回执行结果,需要调用FutureTask.get()得到,此方法会阻塞主进程的继续往下执行,如果不调用不会阻塞。
15 Callable、Future、FutureTask的联系
15.1 Callable
见上面Callable详解,Callable必须由FutureTask包装才能运行!
15.2 Future
Future是java 1.5引入的一个interface,Future接口是用来获取异步计算结果的,说白了就是对具体的Runnable或者Callable对象任务执行的结果进行获取(get()),取消(cancel()),判断是否完成等操作。
意思是当异步执行结束之后,返回的结果将会保存在Future中。
public interface Future<V> {
boolean cancel(boolean mayInterruptIfRunning);
boolean isCancelled();
boolean isDone();
V get() throws InterruptedException, ExecutionException;
V get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException;
}
Future只是一个接口,我们无法直接创建对象,因此就需要其实现类FutureTask登场啦。
当Future.get()不想等待时,如果想取消,那么调用cancel()方法。
15.3 FutureTask
FutureTask类实现了RunnableFuture接口,而RunnnableFuture接口继承了Runnable和Future接口,所以说FutureTask是一个提供异步计算的结果的任务。
public class FutureTask<V> implements RunnableFuture<V> {
...
}
public interface RunnableFuture<V> extends Runnable, Future<V> {
void run();
}
FutureTask 表示一个异步运算的任务。
FutureTask 里面可以传入一个 Callable 的具体实现类,可以对这个异步运算的任务的结果进行等待获取、判断是否已经完成、取消任务等操作。只有当运算完成的时候结果才能取回,如果运算尚未完成 get 方法将会阻塞。一个 FutureTask 对象可以对调用了 Callable 和 Runnable 的对象进行包装,由于 FutureTask 也是Runnable 接口的实现类,所以 FutureTask 也可以放入线程池中。
https://blog.csdn.net/javazejian/article/details/50896505
15.4 如何使用这三个类
查看上面的:13.2 callable使用
16 说说线程的生命周期及五种基本状态?
16.1 线程的生命周期
- 新建状态:
使用 new 关键字和 Thread 类或其子类建立一个线程对象后,该线程对象就处于新建状态。它保持这个状态直到程序 start() 这个线程。 - 就绪状态(可运行状态):
当线程对象调用了start()方法之后,该线程就进入就绪状态。就绪状态的线程处于就绪队列中,要等待JVM里线程调度器的调度。 - 运行状态:
如果就绪状态的`线程获取 CPU 资源,就可以执行 run(),此时线程便处于运行状态。处于运行状态的线程最为复杂,它可以变为阻塞状态、就绪状态和死亡状态。 - 阻塞状态:
是指线程因为某种原因放弃了cpu 使用权,也即让出了时间片,暂时停止运行。直到线程进入可运行(runnable)状态,才有机会再次获得时间片转到运行(running)状态。
阻塞的情况分三种:- 等待阻塞: 运行(running)的线程执行Object.wait()方法,JVM会把该线程放入等待队列(waitting queue)中,使本线程进入到等待阻塞状态;
- 同步阻塞: 运行(running)的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM会把该线程放入锁池(lock pool)中,线程会进入同步阻塞状态;
- 其他阻塞: 运行(running)的线程执行Thread.sleep(long ms)或t.join()方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。
- 死亡状态:
一个运行状态的线程完成任务或者其他终止条件发生时,该线程就切换到终止状态。比如线程run()、main()方法执行结束,或者因异常退出了run()方法,则该线程结束生命周期。死亡的线程不可再次复生。
16.2 线程的状态流转
17 Java 中用到的线程调度算法是什么?
单核CPU,在任意时刻只能执行一条机器指令,每个线程只有获得CPU 的使用权才能执行指令。
所谓多线程的并发运行,其实是指从宏观上看,各个线程轮流获得 CPU 的使用权,分别执行各自的任务。在运行池中,会有多个处于就绪状态的线程在等待 CPU,JAVA 虚拟机的一项任务就是负责线程的调度,线程调度是指按照特定机制为多个线程分配 CPU 的使用权。
有两种调度模型: 分时调度模型和抢占式调度模型。
分时调度模型是指让所有的线程轮流获得 cpu 的使用权,并且平均分配每个线程占用的 CPU 的时间片这个也比较好理解。
Java虚拟机采用抢占式调度模型,是指优先让可运行池中优先级高的线程占用CPU,如果可运行池中的线程优先级相同,那么就随机选择一个线程,使其占用CPU。处于运行状态的线程会一直运行,直至它不得不放弃 CPU。
18 线程的调度策略
调度策略即怎样可以改变线程的运行状态。
线程调度器按优先级来运行线程,但是,如果发生以下情况,就会终止线程的运行:
- 线程体中调用了 yield 方法让出了对 cpu 的占用权利
- 线程体中调用了 sleep 方法使线程进入睡眠状态
- 线程由于 IO 操作受到阻塞
- 另外一个更高优先级线程出现
- 在支持时间片的系统中,该线程的时间片用完
19 什么是线程调度器(Thread Scheduler)和时间分片(Time Slicing )?
线程调度器 是一个操作系统服务,它负责为 Runnable 状态的线程分配 CPU 时间。一旦我们创建一个线程并启动它,它的执行便依赖于线程调度器的实现。
时间分片 是指将可用的 CPU 时间分配给可用的 Runnable 线程的过程。分配 CPU 时间可以基于线程优先级或者线程等待的时间。
线程调度并不受到 Java 虚拟机控制,所以由应用程序来控制它是更好的选择(也就是说不要让你的程序依赖于线程的优先级)。
20 请说出与线程同步以及线程调度相关的方法。
wait(): 使一个线程处于等待(阻塞)状态,并且释放所持有的对象的锁;
sleep(): 使一个正在运行的线程处于睡眠状态,是一个静态方法,调用此方法要处理 InterruptedException 异常;
notify(): 唤醒一个处于等待状态的线程,当然在调用此方法的时候,并不能确切的唤醒某一个等待状态的线程,而是由 JVM 确定唤醒哪个线程,而且与优先级无关;唤醒的线程必须获取锁后才能进入就绪状态。
notityAll(): 唤醒所有处于等待状态的线程,该方法并不是将对象的锁给所有线程,而是让它们竞争,只有获得锁的线程才能进入就绪状态;
21 sleep() 和 wait() 有什么区别?
- 类的不同: sleep() 是 Thread线程类的静态方法,wait() 是 Object类的方法。
- 是否释放锁: sleep() 不释放锁;wait() 释放锁。
- 用途不同: wait 通常被用于线程间交互/通信,sleep 通常被用于暂停执行。
- 用法不同: sleep可以在任何地方使用,而wait只能在同步方法或者同步块中使用。
- 唤醒状态: wait() 方法被调用后,线程不会自动唤醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法,并获取到所资源后才会恢复为就绪状态(“可运行状态”)。sleep() 指定的时间到了又会自动唤醒并恢复为就绪状态(“可运行状态”)。
都可以被interrupted方法中断。
21.1 CPU及资源锁释放
sleep,wait调用后都会暂停当前线程并让出cpu的执行时间,但不同的是:
- sleep不会释放当前持有的对象的锁资源,到时间后获取到时间片后会继续执行。
- wait会放弃所有锁并需要notify/notifyAll后重新获取到对象锁资源后才能继续执行。
21.2 锁对象和CPU的执行权
线程是先取得CPU的执行权,然后执行到synchronized时再获得锁对象的。自动唤醒后,这时如果CPU的执行权没有被其他线程争夺去,那么就可以直接进入Runnable(可运行)状态。
wait(long timeout)是等到timeout后,不需要notify自动唤醒,但是也是需要获取锁才能到就绪状态。
22 你是如何调用 wait() 方法的?使用 if 块还是循环?为什么?
wait只能在同步方法或者同步块中使用,即需要synchronized的配合!!!
处于等待状态的线程可能会收到错误警报和伪唤醒,如果不在循环中检查等待条件,程序就会在没有满足结束条件的情况下退出。
wait) 方法应该在循环调用,因为当线程获取到 CPU 开始执行的时候,其他条件可能还没有满足,所以在处理前,循环检测条件是否满足会更好。
由于notify()和notifyAll()方法会随机唤醒正在等待该对象监视器的线程,满足条件并不总是重要。有时会发生这种情况:该线程已经被唤醒了,但是条件还没有得到满足。
下面是一段标准的使用 wait 和 notify 方法的代码:
synchronized (monitor) {
// 判断条件谓词是否得到满足
while(!locked) {
// 等待唤醒
monitor.wait();
}
// 处理其他的业务逻辑
}
23 为什么线程通信的方法 wait(), notify()和 notifyAll()被定义在 Object 类里?
Java中,任何对象都可以作为锁,并且 wait(),notify()等方法用于等待对象的锁或者唤醒线程,在 Java 的线程中并没有可供任何对象使用的锁,所以任意对象调用方法一定定义在Object类中。
wait(), notify()和 notifyAll()这些方法必须在同步代码块或同步方法中调用。
有的人会说,既然是线程放弃对象锁,那也可以把wait()定义在Thread类里面啊,新定义的线程继承于Thread类,也不需要重新定义wait()方法的实现。然而,这样做有一个非常大的问题,一个线程完全可以持有很多锁,你一个线程放弃锁的时候,到底要放弃哪个锁?当然了,这种设计并不是不能实现,只是管理起来更加复杂。
综上所述,wait()、notify()和notifyAll()方法要定义在Object类中。
24 为什么 wait(), notify()和 notifyAll()必须在同步方法或者同步块中被调用?
当一个线程需要调用对象的 wait()方法的时候,这个线程必须拥有该对象的锁,接着它就会释放这个对象锁并进入等待状态直到其他线程调用这个对象上的 notify()方法。同样的,当一个线程需要调用对象的 notify()方法时,它会释放这个对象的锁,以便其他在等待的线程就可以得到这个对象锁。由于所有的这些方法都需要线程持有对象的锁,这样就只能通过同步来实现,所以他们只能在同步方法或者同步块中被调用。
你现在可以想一下,为什么notifyAll()虽然是唤醒所有处于等待状态的线程,但并不是将对象的锁给所有线程,而是让它们竞争,只有获得锁的线程才能进入就绪状态。
25 notify() 和 notifyAll() 有什么区别?
如果线程调用了对象的 wait()方法,那么线程便会处于该对象的等待池中,等待池中的线程不会去竞争该对象的锁。
notifyAll() 会唤醒所有的线程,notify() 只会唤醒一个线程。
notifyAll() 调用后,会将全部线程由等待池移到锁池,然后参与锁的竞争,竞争成功则继续执行,如果不成功则留在锁池等待锁被释放后再次参与竞争。而 notify()只会唤醒一个线程,具体唤醒哪一个线程由虚拟机控制。
26 Thread 类中的 yield 方法有什么作用?
译为线程让步。顾名思义,就是说当一个线程使用了这个方法之后,它就会把自己CPU执行的时间让掉,让自己或者其它的线程运行,并使当前线程从运行状态(执行状态)变为就绪状态(可执行态)。
让当前线程从运行状态 转为 就绪状态,以允许具有相同优先级的其他线程获得运行机会。
当前线程到了就绪状态,那么接下来哪个线程会从就绪状态变成执行状态呢?可能是当前线程,也可能是其他线程,看系统的分配了。
27 为什么 Thread 类的 sleep()和 yield ()方法是静态的?
Thread类中sleep是静态方法,表示当前线程休眠。
假如sleep是非静态的,则使用示例如下
public void main(String args[]) {
Thread thread = new Thread(new Runable()(
@Override
public void run() {
while(true){
System.out.println("执行线程");
}
}
});
thread.start;
while(true){
System.out.println("main---------------------------");
try {
thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
这个说明:thread.sleep()还是在休眠主线程,而不是休眠 实例所在线程 ,哪还有必要是非静态的吗?
- sleep是静态方法,那么在实现Runnable的线程类中也能使用。
- 线程和实例并不是对等的,不是一个线程是一个实例,是你创建的实例继承了Thread或者Runable,实现了run(),并调用start()的时候能执行多个线程,实例还是一个,线程却是多个。所以实例休眠线程就休眠了这个假设不成立。
- Thread 类的 sleep()和 yield()方法将在当前正在执行的线程上运行。所以在其他处于等待状态的线程上调用这些方法是没有意义的。
28 线程的 sleep()方法和 yield()方法有什么区别?
sleep()方法给其他线程运行机会时不考虑线程的优先级,因此会给低优先级的线程以运行的机会;yield()方法只会给相同优先级或更高优先级的线程以运行的机会;
线程执行 sleep()方法后转入阻塞(blocked)状态,而执行 yield()方法后转入就绪(ready)状态;
sleep()方法声明抛出 InterruptedException,而 yield()方法没有声明任何异常;
sleep()方法比 yield()方法(跟操作系统 CPU 调度相关)具有更好的可移植性,通常不建议使用yield()方法来控制并发线程的执行。
29 如何停止一个正在运行的线程?
在java中有以下3种方法可以终止正在运行的线程:
- 使用退出标志, 使线程正常退出,也就是当run方法完成后线程终止。while(true) if(flag) break or return。
- 使用stop方法强行终止, 但是不推荐这个方法,因为stop和suspend及resume一样都是过期作废的方法。
- 使用interrupt方法中断线程。 调用了,线程并一定是中断。实例方法,此线程不一定是当前线程,而是指调用该方法的Thread实例所代表的线程),但实际上只是给线程设置一个中断标志,线程仍会继续运行。
30 Java 中interrupt、interrupted 和 isInterrupted 三者的区别?
30.1 interrupt()
interrupt():用于中断线程,方法并不是强制终止线程,它只能设置线程的interrupted状态。调用该方法的线程的状态为将被置为”中断”状态。只用在线程的阻塞状态中。
对于阻塞线程,线程在不断检查中断标示,如果发现中断标示为true(即执行了interrupt()方法),则会在这些阻塞方法(sleep、join、wait、1.5中的condition.await及可中断的通道上的 I/O 操作方法)调用处抛出InterruptedException异常,并且在抛出异常后立即将线程的中断标示位清除,即重新设置为false。
使用示例:
public class Test {
public static void main(String[] args) {
Thread td = new Thread(new Runnable(){
@Override
public void run() {
try {
Thread.sleep(100000L);
} catch (InterruptedException e) {
System.out.println("线程是否处于中断状态" + Thread.currentThread().isInterrupted());
e.printStackTrace();
System.out.println("abc");
}
System.out.println("def");
}
});
td.start();
td.interrupt();
}
}
代码执行结果:
线程是否处于中断状态false
线程是否处于中断状态false
abc
def
java.lang.InterruptedException: sleep interrupted
at java.lang.Thread.sleep(Native Method)
at com.wq.zzz.interrupt.Test$1.run(Test.java:9)
at java.lang.Thread.run(Thread.java:748)
注意: 线程中断仅仅是置线程的中断状态位,不会停止线程,线程中断。需要用户自己去监视线程的状态为并做处理。支持线程中断的方法(也就是线程中断后会抛出interruptedException 的方法)就是在监视线程的中断状态,一旦线程的中断状态被置为“中断状态”,就会抛出中断异常。
interrupt()方法是不能中断死锁线程的,因为锁定的位置根本无法抛出异常。
static void deathLock(Object lock1, Object lock2) {
try {
synchronized (lock1) {
Thread.sleep(10);// 不会在这里死掉
synchronized (lock2) {// 会锁在这里,虽然阻塞了,但不会抛异常
System.out.println(Thread.currentThread());
}
}
} catch (InterruptedException e) {
e.printStackTrace();
System.exit(1);
}
}
30.2 interrupted()
- interrupted():是静态方法,查看当前中断信号,是true还是false并且清除中断信号。如果一个线程被中断了,第一次调用 interrupted() 则返回 true,第二次和后面的就返回 false 了。
/**
* Thread.interrupted(); 对设置中断标识的线程复位,并且返回当前的中断状态
* @throws InterruptedException
*/
public void exp3() throws InterruptedException {
Thread thread = new Thread(()->{
while (true){
if (Thread.currentThread().isInterrupted()){
log.info("------"+Thread.currentThread().isInterrupted());
Thread.interrupted();
log.info("-======+=="+Thread.currentThread().isInterrupted());
Thread.interrupted();
log.info("-======+=="+Thread.currentThread().isInterrupted());
Thread.interrupted();
log.info("-======+=="+Thread.currentThread().isInterrupted());
}
}
});
thread.start();
TimeUnit.SECONDS.sleep(11);
thread.interrupt();
}
程序不管跑多久,控制台输出结果都只有四行:
------------true
============false
============false
============false
30.3 isInterrupted()
isInterrupted:查看当前中断信号是true还是false。只是单纯的返回内部线程中断标志的值,真或假。如果线程被中断,那么它将返回真,否则返回假。不会重置当前线程的中断状态。
30 什么是线程中断
线程中断并不是中断线程的执行,仅仅是标识该线程是不是中断状态(是为true,否为false),该标识有两个作用:
- 对于非阻塞线程,需要手动循环去判断这个中断状态,然后根据中断状态来进行一些额外的逻辑处理。如下面的代码所示:
while(!Thread.currentThread().isInterrupted() && more work to do){
do more work
}
- 对于阻塞线程,线程在不断检查中断标示,如果发现中断标示为true(即执行了interrupt()方法),则会在这些阻塞方法(sleep、join、wait、1.5中的condition.await及可中断的通道上的 I/O 操作方法)调用处抛出InterruptedException异常,并且在抛出异常后立即将线程的中断标示位清除,即重新设置为false。
30.1 什么是中断线程
线程的thread.interrupt()方法是中断线程,将会设置该线程的中断状态位,即设置为true,中断的结果线程是死亡、还是等待新的任务或是继续运行至下一步,就取决于这个程序本身。线程会不时地检测这个中断标示位,以判断线程是否应该被中断(中断标示值是否为true)。
它并不像stop方法那样会中断一个正在运行的线程。
当一个线程被阻塞方法阻塞时,interrupt()方法将线程从冻结状态强制恢复到运行状态,让线程具备CPU的执行资格。但是因为是强制动作,所以会抛异常,要记得处理。
30.2 判断线程是否被中断
判断某个线程是否已被发送过中断请求,请使用Thread.currentThread().isInterrupted()方法(因为它将线程中断标示位设置为true后,不会立刻清除中断标示位,即不会将中断标设置为false),而不要使用Thread.interrupted()(静态方法,该方法调用后会将中断标示位清除,即重新设置为false)方法来判断,下面是线程在循环中时的中断方式:
while(!Thread.currentThread().isInterrupted() && more work to do){
do more work
}
其余内容见:【线程】线程中断详解
31 什么是阻塞式方法?
阻塞式方法是指程序会一直等待该方法完成期间不做其他事情,ServerSocket 的accept()方法就是一直等待客户端连接。这里的阻塞是指调用结果返回之前,当前线程会被挂起,直到得到结果之后才会返回。此外,还有异步和非阻塞式方法在任务完成前就返回。
32 Java 中你怎样唤醒一个阻塞的线程?
首先 ,wait()、notify() 方法是针对对象的,调用任意对象的 wait()方法都将导致线程阻塞,阻塞的同时也将释放该对象的锁,相应地,调用任意对象的 notify()方法则将随机解除该对象阻塞的线程,但它需要重新获取该对象的锁,直到获取成功才能往下执行;
其次,wait、notify 方法必须在 synchronized 块或方法中被调用,并且要保证同步块或方法的锁对象与调用 wait、notify 方法的对象是同一个,如此一来在调用 wait 之前当前线程就已经成功获取某对象的锁,执行 wait 阻塞后当前线程就将之前获取的对象锁释放。
32 如何在两个线程间共享数据?
在两个线程间共享变量即可实现共享。即对同一个变量操作。
一般来说,共享变量要求变量本身是线程安全的,然后在线程内使用的时候,如果有对共享变量的复合操作,那么也得保证复合操作的线程安全性。
33 Java 如何实现多线程之间的通讯和协作?
可以通过中断 和 共享变量的方式实现线程间的通讯和协作
比如说最经典的生产者-消费者模型:当队列满时,生产者需要等待队列有空间才能继续往里面放入商品,而在等待的期间内,生产者必须释放对临界资源(即队列)的占用权。因为生产者如果不释放对临界资源的占用权,那么消费者就无法消费队列中的商品,就不会让队列有空间,那么生产者就会一直无限等待下去。因此,一般情况下,当队列满时,会让生产者交出对临界资源的占用权,并进入挂起状态。然后等待消费者消费了商品,然后消费者通知生产者队列有空间了。同样地,当队列空时,消费者也必须等待,等待生产者通知它队列中有商品了。这种互相通信的过程就是线程间的协作。
Java中线程通信协作的最常见的两种方式:
syncrhoized加锁的线程的Object类的wait()/notify()/notifyAll()
ReentrantLock类加锁的线程的Condition类的await()/signal()/signalAll()
线程间直接的数据交换:
通过管道进行线程间通信:1)字节流;2)字符流
34 同步方法和同步块,哪个是更好的选择?
同步块是更好的选择,因为它不会锁住整个对象(当然你也可以让它锁住整个对象)。同步方法会锁住整个对象,哪怕这个类中有多个不相关联的同步块,这通常会导致他们停止执行并需要等待获得这个对象上的锁。
同步块更要符合开放调用的原则,只在需要锁住的代码块锁住相应的对象,这样从侧面来说也可以避免死锁。
请知道一条原则:同步的范围越小越好。
35 什么是线程同步和线程互斥,有哪几种实现方式?
同步就是协同步调,按预定的先后次序进行运行。
线程互斥是指对于共享的进程系统资源,在各单个线程访问时的排它性。
线程间的同步方法大体可分为两类:
- 用户模式
- 内核模式。
顾名思义,内核模式就是指利用系统内核对象的单一性来进行同步,使用时需要切换内核态与用户态,而用户模式就是不需要切换到内核态,只在用户态完成操作。
- 用户模式下的方法有: 原子操作(例如一个单一的全局变量),临界区。
- 内核模式下的方法有: 事件,信号量,互斥量。
实现线程同步的方法:
- 同步代码方法: sychronized 关键字修饰的方法
- 同步代码块: sychronized 关键字修饰的代码块
- 使用特殊变量域volatile实现线程同步: volatile关键字为域变量的访问提供了一种免锁机制
- 使用重入锁实现线程同步: reentrantlock类是可冲入、互斥、实现了lock接口的锁他与sychronized方法具有相同的基本行为和语义
36 在监视器(Monitor)内部,是如何做线程同步的?程序应该做哪种级别的同步?
在 java 虚拟机中,每个对象( Object 和 class )通过某种逻辑关联监视器,每个监视器和一个对象引用相关联,为了实现监视器的互斥功能,每个对象都关联着一把锁。
一旦方法或者代码块被 synchronized 修饰,那么这个部分就放入了监视器的监视区域,确保一次只能有一个线程执行该部分的代码,线程在获取锁之前不允许执行该部分的代码
另外 java 还提供了显式监视器( Lock )和隐式监视器( synchronized )两种锁方案
做代码块级别的同步
37 在 Java 程序中怎么保证多线程的运行安全?
- 使用安全类,比如 java.util.concurrent 下的类,使用原子类AtomicInteger
- 使用自动锁 synchronized。
- 使用手动锁 Lock。
- 使用ThreadLocal。
38 你对线程优先级的理解是什么?
每一个线程都是有优先级的,一般来说,高优先级的线程在运行时会具有优先权,但这依赖于线程调度的实现,这个实现是和操作系统相关的(OS dependent)。我们可以定义线程的优先级,但是这并不能保证高优先级的线程会在低优先级的线程前执行。线程优先级是一个 int 变量(从 1-10),1 代表最低优先级,10 代表最高优先级。
Java 的线程优先级调度会委托给操作系统去处理,所以与具体的操作系统优先级有关,如非特别需要,一般无需设置线程优先级。
39 线程类的构造方法、静态块是被哪个线程调用的
线程类的构造方法、静态块是被 new这个线程类所在的线程所调用的,而 run 方法里面的代码才是被线程自身所调用的。
执行构造函数前会先执行类中的静态代码块。但是不是在类加载的时候就会执行,而是需要类初始化的时候才会执行静态代码块,静态代码块不能在方法里定义。
40 如何在 Java 中获取线程堆栈?
kill -3 [java pid]
不会在当前终端输出,它会输出到代码执行的或指定的地方去。比如,kill -3tomcat pid, 输出堆栈到 log 目录下。
Jstack [java pid]
这个比较简单,在当前终端显示,也可以重定向(>>)到指定文件中。-JvisualVM:Thread Dump
不做说明,打开 JvisualVM 后,都是界面操作,过程还是很简单的。
41 Java 中怎么获取一份线程 dump 文件
Dump文件是进程的内存镜像。有两种方式可以获取dump
41.1 JVM的配置文件中配置相关参数
例如:堆初始化大小,而堆最大大小
在应用启动时配置相关的参数 -XX:+HeapDumpOnOutOfMemoryError,当应用抛出OutOfMemoryError时生成dump文件。
在启动的时候,配置文件在哪个目录下面:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=目录+产生的时间.hprof
JVM启动时增加两个参数:
#出现 OOME 时生成堆 dump:
-XX:+HeapDumpOnOutOfMemoryError
#生成堆文件地址:
-XX:HeapDumpPath=/home/liuke/jvmlogs/
41.2 发现程序异常前通过执行指令,直接生成当前JVM的dmp文件
jmap -dump:file=文件名.dump [pid]
jmap -dump:format=b,file=serviceDump.dat 6214 // 6214是指JVM的进程号
41.3 比较
由于第一种方式是一种事后方式,需要等待当前JVM出现问题后才能生成dmp文件,实时性不高,第二种方式在执行时,JVM是暂停服务的,所以对线上的运行会产生影响。所以建议第一种方式。
42 一个线程运行时发生异常会怎样?
- 如果该异常被捕获或抛出,则程序继续运行。不会释放锁
- 如果异常没有被捕获该线程将会停止执行。会释放锁
43 Java 线程数过多会有什么影响
- 降低稳定性,比如线程栈是需要分配内存空间的,过多可能会出现OutOfMemoryError。
- 浪费资源,cpu切换线程涉及到上下文恢复,这个是需要耗费时间的
44 怎么唤醒一个阻塞的线程
- 如果线程是因为调用了wait()、sleep()或者join()方法而导致的阻塞,可以中断线程,并且通过抛出InterruptedException来唤醒它;
- 如果线程遇到了IO阻塞,无能为力,因为IO是操作系统实现的,Java代码并没有办法直接接触到操作系统。
中断??是
45 不可变对象对多线程有什么帮助
前面有提到过的一个问题,不可变对象保证了对象的内存可见性,对不可变对象的读取不需要进行额外的同步手段,提升了代码执行效率。
什么是不可变对象? final修饰的对象才是吗?
46 单例模式的线程安全性
老生常谈的问题了,首先要说的是单例模式的线程安全意味着:某个类的实例在多线程环境下只会被创建一次出来。单例模式有很多种的写法,我总结一下:
(1)饿汉式单例模式的写法:线程安全
(2)懒汉式单例模式的写法:非线程安全
(3)双检锁单例模式的写法:线程安全
46.1 饿汉式
一个类只能创建一个对象
- 私有化构造器
- 在类的内部创建一个类的实例,且为static
- 私有化对象,通过公共方法调用
- 此公共方法只能通过类来调用,因为设置的是static,同时类的实例也是static
package com.wq.test;
public class TestSingleton {
public static void main (String[] args){
Singleton s1 = Singleton.getInstance();
Singleton s2 = Singleton.getInstance();
System.out.println(s1 == s2); //true
}
}
class Singleton{
//1.私有化构造器
private Singleton(){
}
//2.在类中创建一个类的实例,私有化,静态的
private static Singleton instance = new Singleton();
// 3.通过公共方法调用,此公共方法只能类调用,因为设置了 static
public static Singleton getInstance(){
return instance;
}
}
46.2 懒汉式
- 私有化构造器
- 创建一个私有的实例static 先不实例化 为 null
- 通过公共方法调用 static 在方法里面进行判断,if = null,实例化 !=null 直接return。
class Singleton{
//1.私有化构造器
private Singleton(){}
//2.创建一个私有的实例为static 且值设置为null
private static Singleton instance = null;
//3.通过公共方法调用,static
public static Singleton getInstance(){
if (instance == null){
instance = new Singleton();
}
return instance;
}
}
双检锁单例模式
public class SingleDemo {
private static SingleDemo s = null;
private SingleDemo(){}
public static SingleDemo getInstance(){
/*如果第一个线程获取到了单例的实例对象,
* 后面的线程再获取实例的时候不需要进入同步代码块中了*/
if(s == null){
//同步代码块用的锁是单例的字节码文件对象,且只能用这个锁
synchronized(SingleDemo.class){
if(s == null){
s = new SingleDemo();
}
}
}
return s;
}
}
47 java线程池如何合理配置核心线程数?
1.先看下机器的CPU核数,然后在设定具体参数:
System.out.println(Runtime.getRuntime().availableProcessors());
即CPU核数 = Runtime.getRuntime().availableProcessors()
2.分析下线程池处理的程序是CPU密集型,还是IO密集型
CPU密集型:核心线程数 = CPU核数 + 1
IO密集型:核心线程数 = CPU核数 * 2
注:IO密集型(某大厂实践经验)
核心线程数 = CPU核数 / (1-阻塞系数) 例如阻塞系数 0.8,CPU核数为4
则核心线程数为20
阻塞系数= 总运行时间/线程阻塞时间
Java 提供了 ThreadMXBean 接口(通过 JMX)来监控线程状态和时间
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadMXBean;
public class ThreadBlockingMonitor {
public static void main(String[] args) {
ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
// 获取所有线程的 ID
long[] threadIds = threadMXBean.getAllThreadIds();
for (long threadId : threadIds) {
// 获取线程信息
ThreadInfo threadInfo = threadMXBean.getThreadInfo(threadId);
// 线程状态:BLOCKED, WAITING, TIMED_WAITING
Thread.State state = threadInfo.getThreadState();
// 累计阻塞时间(需启用线程时间统计)
long blockedTime = threadMXBean.getThreadUserTime(threadId);
// 总运行时间 = 当前时间 - 线程启动时间
}
}
}
然后启用线程时间统计,在 JVM 启动时添加参数:
-Djava.lang.management.ThreadMXBean.supportedThreadContentionMonitoringEnabled=true
在使用工具查看统计
比如使用Java Mission Control (JMC)
记录线程的阻塞事件和持有锁的时间。
生成火焰图分析阻塞热点。
阻塞时间:线程处于 BLOCKED、WAITING 或 TIMED_WAITING 状态的时间。
如果需要更具体的监控方案(如分布式系统中的线程阻塞分析),可以结合 APM 工具(如 SkyWalking、Pinpoint)。
48 什么是CPU密集型、IO密集型?
CPU密集型(CPU-bound)
CPU密集型也叫计算密集型,指的是系统的硬盘、内存性能相对CPU要好很多,此时,系统运作大部分的状况是CPU Loading 100%,CPU要读/写I/O(硬盘/内存),I/O在很短的时间就可以完成,而CPU还有许多运算要处理,CPU Loading很高。
在多重程序系统中,大部份时间用来做计算、逻辑判断等CPU动作的程序称之CPU bound。例如一个计算圆周率至小数点一千位以下的程序,在执行的过程当中绝大部份时间用在三角函数和开根号的计算,便是属于CPU bound的程序。
CPU bound的程序一般而言CPU占用率相当高。这可能是因为任务本身不太需要访问I/O设备,也可能是因为程序是多线程实现因此屏蔽掉了等待I/O的时间。
IO密集型(I/O bound)
IO密集型指的是系统的CPU性能相对硬盘、内存要好很多,此时,系统运作,大部分的状况是CPU在等I/O (硬盘/内存) 的读/写操作,此时CPU Loading并不高。
I/O bound的程序一般在达到性能极限时,CPU占用率仍然较低。这可能是因为任务本身需要大量I/O操作,而pipeline做得不是很好,没有充分利用处理器能力。
- IO密集型任务
一般来说:内存/硬盘操作、文件读写、DB读写、网络请求等
- CPU密集型任务
一般来说:计算型代码、Bitmap转换、Gson转换、视频解码等
二、并发理论
包含Java内存模型、重排序与数据依赖性、并发关键字、Lock体系
0 Java内存模型(JMM)
原文:全面学习掌握Java内存模型
面试时回答:
JMM规范了Java虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。
Java 内存模型是一种规范,定义了很多东西:
- 所有的变量都存储在主内存(Main Memory)中。
- 每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的拷贝副本。
- 线程对变量的所有操作都必须在本地内存中进行,而不能直接读写主内存。
- 不同的线程之间无法直接访问对方本地内存中的变量。
0.1 前言
在面试中,面试官经常喜欢问:『说说什么是Java内存模型(JMM)?』
面试者内心狂喜,这题刚背过:『Java内存主要分为五大块:堆、方法区、虚拟机栈、本地方法栈、PC寄存器,balabala……』
面试官会心一笑,露出一道光芒:『好了,今天的面试先到这里了,回去等通知吧』
一般听到等通知这句话,这场面试大概率就是凉凉了。为什么呢?因为面试者弄错了概念,面试官是想考察JMM,但是面试者一听到 “Java内存”这几个关键字就开始背诵八股文了。Java内存模型(JMM)和 Java 运行时内存区域区别可大了呢
0.2 概述
常说的JVM内存模式指的是JVM的内存分区;而Java内存模式是一种虚拟机规范。
Java虚拟机规范中定义了Java内存模型(Java Memory Model,JMM),用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果,JMM规范了Java虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。
0.3 为什么要有内存模型?
0.3.1 缓存一致性问题
由于主存与 CPU 处理器的运算能力之间有数量级的差距,所以在传统计算机内存架构中会引入高速缓存来作为主存和处理器之间的缓冲,CPU 将常用的数据放在高速缓存中,运算结束后 CPU 再讲运算结果同步到主存中。
使用高速缓存解决了 CPU 和主存速率不匹配的问题,但同时又引入另外一个新问题:缓存一致性问题。
在多CPU的系统中(或者单CPU多核的系统),每个CPU内核都有自己的高速缓存,它们共享同一主内存(Main Memory)。当多个CPU的运算任务都涉及同一块主内存区域时,CPU 会将数据读取到缓存中进行运算,这可能会导致各自的缓存数据不一致。
因此需要每个 CPU 访问缓存时遵循一定的协议,在读写数据时根据协议进行操作,共同来维护缓存的一致性。这类协议有 MSI、MESI、MOSI、和 Dragon Protocol 等。
0.3.2 处理器优化和指令重排序
为了使处理器内部的运算单元能够最大化被充分利用,处理器会对输入代码进行乱序执行处理。
现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
很多现代编程语言的编译器也会做类似的优化,比如像 Java 的即时编译器(JIT)会做指令重排序。编译器在不改变单线程程序语义放入前提下,可以重新安排语句的执行顺序。
0.4 并发编程的问题
上面讲了一堆硬件相关的东西,有些同学可能会有点懵,绕了这么大圈,这些东西跟 Java 内存模型有啥关系吗?不要急咱们慢慢往下看。
熟悉 Java 并发的同学肯定对这三个问题很熟悉:『可见性问题』、『原子性问题』、『有序性问题』。如果从更深层次看这三个问题,其实就是上面讲的『缓存一致性』、『处理器优化』、『指令重排序』造成的。
缓存一致性问题其实就是可见性问题,处理器优化可能会造成原子性问题,指令重排序会造成有序性问题,你看是不是都联系上了。
出了问题总是要解决的,那有什么办法呢?首先想到简单粗暴的办法,干掉缓存让 CPU 直接与主内存交互就解决了可见性问题,禁止处理器优化和指令重排序就解决了原子性和有序性问题,但这样一夜回到解放前了,显然不可取。
所以技术前辈们想到了在物理机器上定义出一套内存模型, 规范内存的读写操作。内存模型解决并发问题主要采用两种方式:
- 限制处理器优化。
- 使用内存屏障。
0.5 内存模型
0.5.1 Java 运行时内存区域与硬件内存的关系
JVM 运行时内存区域是分片的,分为栈、堆等,其实这些都是 JVM 定义的逻辑概念。在传统的硬件内存架构中是没有栈和堆这种概念。
从图中可以看出栈和堆既存在于高速缓存中又存在于主内存中,所以两者并没有很直接的关系。
0.5.2 Java 线程与主内存的关系
Java 内存模型是一种规范,定义了很多东西:
- 所有的变量都存储在主内存(Main Memory)中。
- 每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的拷贝副本。
- 线程对变量的所有操作都必须在本地内存中进行,而不能直接读写主内存。
- 不同的线程之间无法直接访问对方本地内存中的变量。
0.5.3 线程间通信
如果两个线程都对一个共享变量进行操作,共享变量初始值为 1,每个线程都变量进行加 1,预期共享变量的值为 3。在 JMM 规范下会有一系列的操作。
1 Java中垃圾回收有什么目的?什么时候进行垃圾回收?
垃圾回收的目的是识别并且丢弃应用不再使用的对象来释放和重用资源。
垃圾回收可以手动调用gc,一般是系统等到新生代的内存区占满了又需要分配内存的时候,这个时候新生代就变成了老年代,等老年代的内存占满之后开始回收老年代所占的内存区。
2 如果对象的引用被置为null,垃圾收集器是否会立即释放对象占用的内存?
不会,在下一个垃圾回调周期中,这个对象将是被可回收的。
也就是说并不会立即被垃圾收集器立刻回收,而是在下一次垃圾回收时才会释放其占用的内存。
3 finalize()方法什么时候被调用?析构函数(finalization)的目的是什么?
1)垃圾回收器(garbage colector)决定回收某对象时,就会运行该对象的finalize()方法;
finalize是Object类的一个方法,该方法在Object类中的声明protected void finalize() throws Throwable { }
在垃圾回收器执行时会调用被回收对象的finalize()方法,可以覆盖此方法来实现对其资源的回收。注意:一旦垃圾回收器准备释放对象占用的内存,将首先调用该对象的finalize()方法,并且下一次垃圾回收动作发生时,才真正回收对象占用的内存空间
2)GC本来就是内存回收了,应用还需要在finalization做什么呢? 答案是大部分时候,什么都不用做(也就是不需要重载)。只有在某些很特殊的情况下,比如你调用了一些native的方法(一般是C写的),可以要在finaliztion里去调用C的释放函数。
4 为什么代码会重排序?
在执行程序时,为了提供性能,处理器和编译器常常会对指令进行重排序,但是不能随意重排序,不是你想怎么排序就怎么排序,它需要满足以下两个条件:
在单线程环境下不能改变程序运行的结果;
存在数据依赖关系的不允许重排序
需要注意的是:重排序不会影响单线程环境的执行结果,但是会破坏多线程的执行语义。
5 as-if-serial规则和happens-before规则的区别
as-if-serial语义保证单线程内程序的执行结果不被改变
happens-before关系保证正确同步的多线程程序的执行结果不被改变。
as-if-serial语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。
happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的。
as-if-serial语义和happens-before这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。
6 synchronized 的作用?
在 Java 中,synchronized 关键字是用来控制线程同步的,就是在多线程的环境下,控制 synchronized 代码段不被多个线程同时执行。
- 可以保证在同一个时刻,只有一个线程可以执行某个方法或者某个代码块
- 可以保证一个线程的变化(主要是共享数据的变化)被其他线程所看到(保证可见性,完全可以替代Volatile功能)
synchronized 可以修饰类、方法、变量:
修饰一个类:其作用的范围是synchronized后面括号括起来的部分,作用的对象是这个类的所有对象;类似于修饰一个静态方法,只不过写法不一样,synchronized (ObjectService.class){…不管有几个对象就公用一把锁。
修饰一个方法:被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象;
修饰一个静态的方法:其作用的范围是整个方法,作用的对象是这个类的所有对象;
修饰一个代码块:被修饰的代码块称为同步语句块,其作用范围是大括号{}括起来的代码块,作用的对象是调用这个代码块的对象;
在 Java 早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的 synchronized 效率低的原因。
Java 6 之后 Java 官方对从 JVM 层面对synchronized 较大优化,所以现在的 synchronized 锁效率也优化得很不错了。JDK1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
总结: synchronized 关键字加到 static 静态方法和 synchronized(class)代码块上都是是给 Class 类上锁。synchronized 关键字加到实例方法上是给对象实例上锁。尽量不要使用 synchronized(String a) 因为JVM中,字符串常量池具有缓存功能!
7 单例模式中的synchronized
双重校验锁实现对象单例(线程安全)
public class Singleton {
private volatile static Singleton uniqueInstance;
private Singleton() {
}
public static Singleton getUniqueInstance() {
//先判断对象是否已经实例过,没有实例化过才进入加锁代码
if (uniqueInstance == null) {
//类对象加锁
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}
uniqueInstance 采用 volatile 关键字修饰也是很有必要的, uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:
- 为 uniqueInstance 分配内存空间
- 初始化 uniqueInstance
- 将 uniqueInstance 指向分配的内存地址
但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。
有些资料却说:
把instance声明为volatile之后,对它的写操作就会有一个内存屏障(什么是内存屏障?),这样,在它的赋值完成之前,就不用会调用读操作。volatile阻止的不是singleton = newSingleton()这句话内部[1-2-3]的指令重排,而是保证了在一个写操作([1-2-3])完成之前,不会调用读操作(if (instance == null))。
导致哪个是真???
使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。
8 说一下 synchronized 底层实现原理?
synchronized是Java中的一个关键字,在使用的过程中并没有看到显示的加锁和解锁过程。因此有必要通过javap命令,查看相应的字节码文件。
JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但两者的实现细节不一样:
同步代码块是由 monitorenter 和 monitorexit 指令来实现同步的,
同步方法是由方法调用指令读取运行时常量池中方法的 ACC_SYNCHRONIZED 标志来隐式实现同步的,方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。
monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。
任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。
具体实现细节:一文读懂之 — synchronized关键字
8.0 理解Java对象头与Monitor
详情---- 一定要去看:一文读懂之 — synchronized关键字 3.1章节
Java头对象,它实现synchronized的锁对象的基础,synchronized使用的锁对象是存储在Java对象头里的。
类锁是基于对类对应的 java.lang.Class对象加锁信息
对象头里MarkWord中的指针指向的是monitor对象,
在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的。
其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的)
ObjectMonitor() {
_header = NULL;
_count = 0; //记录个数
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程。
当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1,若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。
由此看来,monitor对象存在于每个Java对象的对象头中(存储的指针的指向),synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时也是notify/notifyAll/wait等方法存在于顶级对象Object中的原因。
8.1 同步代码块
synchronized 同步语句块的情况
public class SynchronizedDemo {
public void method() {
synchronized (this) {
System.out.println("synchronized 代码块");
}
}
}
通过JDK 反汇编指令 javap -c -v SynchronizedDemo
可以看出在执行同步代码块之前之后都有一个monitor字样,其中前面的是monitorenter,后面的是离开monitorexit,不难想象一个线程也执行同步代码块,首先要获取锁,而获取锁的过程就是monitorenter ,在执行完代码块之后,要释放锁,释放锁就是执行monitorexit指令。
当执行monitorenter指令时,当前线程将试图获取 objectref(即对象锁) 所对应的 monitor 的持有权,当 objectref 的 monitor 的进入计数器为 0,那线程可以成功取得 monitor,并将计数器值设置为 1,取锁成功。
8.1.1 为什么会有两个monitorexit呢?
这个主要是防止在同步代码块中线程因异常退出,而锁没有得到释放,这必然会造成死锁(等待的线程永远获取不到锁)。因此最后一个monitorexit是保证在异常情况下,锁也可以得到释放,避免死锁。
仅有ACC_SYNCHRONIZED这么一个标志,该标记表明线程进入该方法时,需要monitorenter,退出该方法时需要monitorexit。
8.1.2 synchronized可重入的原理
重入锁是指一个线程获取到该锁之后,该线程可以继续获得该锁。底层原理维护一个计数器,当线程获取该锁时,计数器加一,再次获得该锁时继续加一,释放锁时,计数器减一,当计数器值为0时,表明该锁未被任何线程所持有,其它线程可以竞争获取锁。
8.2 synchronized方法底层原理
方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。
JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。
== 当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor(虚拟机规范中用的是管程一词), 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。==
如果一个同步方法执行期间抛 出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放。所以当一个线程中的同步方法发生了异常且方法内部无法处理,将会释放锁。
9 多线程中 synchronized 锁升级的原理是什么?
synchronized 锁升级原理:
在锁对象的对象头里面有一个 threadid 字段,在第一次访问的时候 threadid 为空,jvm 让其持有偏向锁,并将 threadid 设置为其线程 id,再次进入的时候会先判断 threadid 是否与其线程 id 一致,如果一致则可以直接使用此对象,如果不一致,则升级偏向锁为轻量级锁,通过自旋循环一定次数来获取锁,执行一定次数之后,如果还没有正常获取到要使用的对象,此时就会把锁从轻量级升级为重量级锁,此过程就构成了 synchronized 锁的升级。
锁的升级的目的:
锁升级是为了减低了锁带来的性能消耗。在 Java 6 之后优化 synchronized 的实现方式,使用了偏向锁升级为轻量级锁再升级到重量级锁的方式,从而减低了锁带来的性能消耗。
9 偏向锁
如果开启了偏向锁(默认开启),那么对象创建后,Mark Word 的后三位为101,即锁的初始化状态时是偏向的。这时他的thread,epoch,age都为0。
偏向锁时默认延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加VM参数 - XX : BiasedLockingStartupDelay来禁用延迟。
如果没有开启偏向锁,那么对象创建后,markword值为0x01,即无锁状态。
10 synchronized如何保证原子性、有序性和可见性?
原子性: 即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
Java内存模型提供了字节码指令monitorenter和monitorexit来隐式的使用这两个操作,在synchronized块之间的操作是具备原子性的。
线程1在执行monitorenter指令的时候,会对Monitor进行加锁,加锁后其他线程无法获得锁,除非线程1主动解锁。即使在执行过程中,由于某种原因,比如CPU时间片用完,线程1放弃了CPU,但是它并没有进行解锁。而由于synchronized的锁是可重入的,下一个时间片还是只能被他自己获取到,还是会继续执行代码。直到所有代码执行完。这就保证了原子性。
有序性: 程序执行的顺序按照代码的先后顺序执行。
在并发时,程序的执行可能会出现乱序。给人的直观感觉就是:写在前面的代码,会在后面执行。但是synchronized提供了有序性保证,这其实和as-if-serial语义有关。
as-if-serial语义是指不管怎么重排序(编译器和处理器为了提高并行度),单线程程序的执行结果都不能被改变。编译器和处理器无论如何优化,都必须遵守as-if-serial语义。只要编译器和处理器都遵守了这个语义,那么就可以认为单线程程序是按照顺序执行的,由于synchronized修饰的代码,同一时间只能被同一线程访问。那么可以认为是单线程执行的。所以可以保证其有序性。
但是需要注意的是synchronized虽然能够保证有序性,但是无法禁止指令重排和处理器优化的。
可见性: 当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
被synchronized修饰的代码,在开始执行时会加锁,执行完成后会进行解锁,但在一个变量解锁之前,必须先把此变量同步回主存中,这样解锁后,后续其它线程就可以访问到被修改后的值,从而保证可见性。
11 线程 B 怎么知道线程 A 修改了变量
(1)volatile 修饰变量
(2)synchronized 修饰修改变量的方法
(3)wait/notify
(4)while 轮询
12 synchronized、volatile、CAS 比较
(1)synchronized 是悲观锁,属于抢占式,会引起其他线程阻塞。
(2)volatile 提供多线程共享变量可见性和禁止指令重排序优化。
(3)CAS 是基于冲突检测的乐观锁(非阻塞)
13 synchronized 和 Lock 有什么区别?
- 首先synchronized是Java内置关键字,在JVM层面,Lock是个Java接口类;
- synchronized 可以给类、方法、代码块加锁;而 lock 只能给代码块加锁。
- synchronized 不需要手动获取锁和释放锁,使用简单,发生异常不会造成死锁,会自动释放锁;而 lock 需要自己加锁和释放锁,如果使用不当没有 unLock()去释放锁就会造成死锁。
- 通过 Lock 可以知道有没有成功获取锁(isLocked()),而 synchronized 却无法办到。
14 synchronized 和 ReentrantLock 区别是什么?
synchronized 是和 if、else、for、while 一样的关键字,ReentrantLock 是类,这是二者的本质区别。既然 ReentrantLock 是类,那么它就提供了比synchronized 更多更灵活的特性,可以被继承、可以有方法、可以有各种各样的类变量.
相同点
两者都是可重入锁
两者都是可重入锁。“可重入锁”概念是:自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重入的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。
主要区别如下:
ReentrantLock 使用起来比较灵活,但是必须有释放锁的配合动作;
ReentrantLock 必须手动获取与释放锁,而 synchronized 不需要手动释放和开启锁;
ReentrantLock 只适用于代码块锁,而 synchronized 可以修饰类、方法、变量等。
二者的锁机制其实也是不一样的。ReentrantLock 底层调用的是 Unsafe 的park 方法加锁,synchronized 操作的应该是对象头中 mark word
15 volatile 关键字的作用
一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:
- 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
- 禁止进行指令重排序。
15 volatile可见性和禁止重排序的原理
由lock指令实现了内存可见性和禁止指令重排序
会在汇编语言中加入一个lock指令,该指令会将当前处理器缓存行的数据立刻写回到系统的内存中,并且这个写回内存的操作还会引起在其他cpu里缓存了该内存地址的数据无效 (MESI协议)cpu总线嗅探机制
16 Java 中能创建 volatile 数组吗?
能,Java 中可以创建 volatile 类型数组,不过只是一个指向数组的引用,而不是整个数组。意思是,如果改变引用指向的数组,将会受到 volatile 的保护,但是如果多个线程同时改变数组的元素,volatile 标示符就不能起到之前的保护作用了。
17 volatile 变量和 atomic 变量有什么不同?
volatile 变量可以确保先行关系,即写操作会发生在后续的读操作之前, 但它并不能保证原子性。例如用 volatile 修饰 count 变量,那么 count++ 操作就不是原子性的。
i++操作可以被拆分为三步:
- load:线程读取i的值
- user:i进行自增计算
- assign:刷新回i的值
而 AtomicInteger 类提供的 atomic 方法可以让这种操作具有原子性如getAndIncrement()方法会原子性的进行增量操作把当前值加一,其它数据类型和引用变量也可以进行相似操作。
18 volatile 能使得一个非原子操作变成原子操作吗?
关键字volatile的主要作用是使变量在多个线程间可见,但无法保证原子性,对于多个线程访问同一个实例变量需要加锁进行同步。
虽然volatile只能保证可见性不能保证原子性,但用volatile修饰long和double可以保证其操作原子性。
为什么呢?为什么long、double读写就不是原子性,不像int那样,这里的读写不是指++自增,就是普通的long a = 1000000 这样的写。
原因: long 和 double 都是 8 字节长度的类型,也就是有 64 位。JVM 规范出来的较早,那时候处理器还不能处理 64 位字长,所以 JVM 规范里定义的是 32 位字长的读写是原子的,而 64 位字长需要分成两次来操作。
所以从Oracle Java Spec里面可以看到:
- 对于64位的long和double,如果没有被volatile修饰,那么对其操作可以不是原子的。在操作的时候,可以分成两步,每次对32位操作。
- 如果使用volatile修饰long和double,那么其读写都是原子操作
- 对于64位的引用地址的读写,都是原子操作
- 在实现JVM时,可以自由选择是否把读写long和double作为原子操作
- 推荐JVM实现为原子操作
规范是规范,现在 JVM 中的 long, double 读写应该就是原子操作了吧,不然使用 long 和 double 还不都得加上 volatile
19 volatile 修饰符的有过什么实践?
在单例模式中使用过volatile。
对于Double-Check这种可能出现的问题(当然这种概率已经非常小了,但毕竟还是有的嘛~),解决方案是:只需要给instance的声明加上volatile关键字即可volatile关键字的一个作用是禁止指令重排,把instance声明为volatile之后,对它的写操作就会有一个内存屏障(什么是内存屏障?),这样,在它的赋值完成之前,就不用会调用读操作。
19 volatile修饰的数组时,数组的元素是否对其他线程存在着可见性?
从规范上来说,volatile的数组只针对数组的引用具有volatile的语义,而不是它的元素。
但是从以下代码来看volatile数组的元素具有可见性。
package com.wq.zzz.volatiles;
import java.util.concurrent.TimeUnit;
public class TestVolatile {
public static volatile long[] arr = new long[20];
public static void main(String[] args) throws Exception {
//线程1
new Thread(new Thread(){
@Override
public void run() {
//Thread A
try {
TimeUnit.MILLISECONDS.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
arr[19] = 2;
System.out.println(arr[19]);
}
}).start();
//线程2
new Thread(new Thread(){
@Override
public void run() {
//Thread B
while (arr[19] != 2) {
}
System.out.println("Jump out of the loop!");
}
}).start();
}
}
上面的代码如果数据去掉volatile修饰,则不会打印Jump out of the loop!!!所以又感觉会有可见性!从汇编的角度来看是不具有的!
具体看:volatile是否能保证数组中元素的可见性?
volatile修饰数组,那么数组元素可见吗?
20 synchronized 和 volatile 的区别是什么?
synchronized 表示只有一个线程可以获取作用对象的锁,执行代码,阻塞其他线程。
volatile 表示变量在 CPU 的寄存器中是不确定的,必须从主存中读取。保证多线程环境下变量的可见性;禁止指令重排序。
区别:
volatile 是变量修饰符;synchronized 可以修饰类、方法、变量。
volatile 仅能实现变量的修改可见性,不能保证原子性;而 synchronized 则可以保证变量的修改可见性和原子性。
volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞。
volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化。
volatile关键字是线程同步的轻量级实现,所以volatile性能肯定比synchronized关键字要好。但是volatile关键字只能用于变量而synchronized关键字可以修饰方法以及代码块。synchronized关键字在JavaSE1.6之后进行了主要包括为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁以及其它各种优化之后执行效率有了显著提升,实际开发中使用 synchronized 关键字的场景还是更多一些。
21 什么是不可变对象,它对写并发应用有什么帮助?
不可变对象(Immutable Objects)即对象一旦被创建它的状态(对象的数据,也即对象属性值)就不能改变,反之即为可变对象(Mutable Objects)。
不可变对象的类即为不可变类(Immutable Class)。Java 平台类库中包含许多不可变类,如 String、基本类型的包装类、BigInteger 和 BigDecimal 等。
只有满足如下状态,一个对象才是不可变的;
它的状态不能在创建后再被修改;
所有域都是 final 类型;并且,它被正确创建(创建期间没有发生 this 引用的逸出)。
不可变对象保证了对象的内存可见性,对不可变对象的读取不需要进行额外的同步手段,提升了代码执行效率。
22 Java Concurrency API 中的 Lock 接口(Lock interface)是什么?对比同步它有什么优势?
Lock 接口比同步方法和同步块提供了更具扩展性的锁操作。他们允许更灵活的结构,可以具有完全不同的性质,并且可以支持多个相关类的条件对象。
它的优势有:
可以使锁更公平
可以使线程在等待锁的时候响应中断
可以让线程尝试获取锁,并在无法获取锁的时候立即返回或者等待一段时间
可以在不同的范围,以不同的顺序获取和释放锁
整体上来说 Lock 是 synchronized 的扩展版,Lock 提供了无条件的、可轮询的(tryLock 方法)、定时的(tryLock 带参方法)、可中断的(lockInterruptibly)、可多条件队列的(newCondition 方法)锁操作。
Lock 的实现类基本都支持非公平锁(默认)和公平锁,synchronized 只支持非公平锁,当然,在大部分情况下,非公平锁是高效的选择。
23 乐观锁和悲观锁的理解及如何实现,有哪些实现方式?
悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。再比如 Java 里面的同步原语 synchronized 关键字的实现也是悲观锁。
乐观锁:顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于 write_condition 机制,其实都是提供的乐观锁。在 Java中 java.util.concurrent.atomic 包下面的原子变量类就是使用了乐观锁的一种实现方式 CAS 实现的。
乐观锁的实现方式:
使用版本标识来确定读到的数据与提交时的数据是否一致。提交后修改版本标识,不一致时可以采取丢弃和再次尝试的策略。
java 中的 Compare and Swap 即 CAS ,当多个线程尝试使用 CAS 同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。
CAS 操作中包含三个操作数 —— 需要读写的内存位置(V)、进行比较的预期原值(A)和拟写入的新值(B)。如果内存位置 V 的值与预期原值 A 相匹配,那么处理器会自动将该位置值更新为新值 B。否则处理器不做任何操作。
24 什么是 CAS
CAS 是 compare and swap 的缩写,即我们所说的比较交换。
cas 是一种基于锁的操作,而且是乐观锁。在 java 中锁分为乐观锁和悲观锁。悲观锁是将资源锁住,等一个之前获得锁的线程释放锁之后,下一个线程才可以访问。而乐观锁采取了一种宽泛的态度,通过某种方式不加锁来处理资源,比如通过给记录加 version 来获取数据,性能较悲观锁有很大的提高。
CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存地址里面的值和 A 的值是一样的,那么就将内存里面的值更新成 B。CAS是通过无限循环来获取数据的,若果在第一轮循环中,a 线程获取地址里面的值被b 线程修改了,那么 a 线程需要自旋,到下次循环才有可能机会执行。
java.util.concurrent.atomic 包下的类大多是使用 CAS 操作来实现的(AtomicInteger,AtomicBoolean,AtomicLong)。
25 CAS 的会产生什么问题?
1、ABA 问题:
比如说一个线程 one 从内存位置 V 中取出 A,这时候另一个线程 two 也从内存中取出 A,并且 two 进行了一些操作变成了 B,然后 two 又将 V 位置的数据变成 A,这时候线程 one 进行 CAS 操作发现内存中仍然是 A,然后 one 操作成功。尽管线程 one 的 CAS 操作成功,但可能存在潜藏的问题。从 Java1.5 开始 JDK 的 atomic包里提供了一个类 AtomicStampedReference 来解决 ABA 问题。
2、循环时间长开销大:
对于资源竞争严重(线程冲突严重)的情况,CAS 自旋的概率会比较大,从而浪费更多的 CPU 资源,效率低于 synchronized。
3、只能保证一个共享变量的原子操作:
当对一个共享变量执行操作时,我们可以使用循环 CAS 的方式来保证原子操作,但是对多个共享变量操作时,循环 CAS 就无法保证操作的原子性,这个时候就可以用锁。
26 AQS 介绍
AQS的全称为(AbstractQueuedSynchronizer),这个类在java.util.concurrent.locks包下面。
AQS是AbustactQueuedSynchronizer的简称,它是一个Java提高的底层同步工具类,用一个int类型的变量表示同步状态,并提供了一系列的CAS操作来管理这个同步状态。
AQS是一个用来构建锁和同步器的框架,使用AQS能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的ReentrantLock,Semaphore,其他的诸如ReentrantReadWriteLock,SynchronousQueue,FutureTask等等皆是基于AQS的。
当然,我们自己也能利用AQS非常轻松容易地构造出符合我们自己需求的同步器。
27 AQS 原理分析
AQS核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。
CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS是将每条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node)来实现锁的分配。
看个AQS(AbstractQueuedSynchronizer)原理图:
AQS使用一个int成员变量来表示同步状态,通过内置的FIFO队列来完成获取资源线程的排队工作。AQS使用CAS对该同步状态进行原子操作实现对其值的修改。
private volatile int state;//共享变量,使用volatile修饰保证线程可见性
状态信息通过protected类型的getState,setState,compareAndSetState进行操作
//返回同步状态的当前值
protected final int getState() {
return state;
}
// 设置同步状态的值
protected final void setState(int newState) {
state = newState;
}
//原子地(CAS操作)将同步状态值设置为给定值update如果当前同步状态的值等于expect(期望值)
protected final boolean compareAndSetState(int expect, int update) {
return unsafe.compareAndSwapInt(this, stateOffset, expect, update); //这个是native方法,不是java实现的。
}
CAS底层是操作系统来控制的,unsafe.compareAndSwapInt是Native方法。
27.0 同步队列
队列同步器的实现依赖内部的同步队列来完成同步状态的管理。它是一个FIFO的双向队列,当前程获取同步状态失败时,同步器会将当前线程和等待状态等信息包装成一个节点Node并将其加入同步队列队尾,同时会阻塞当前线程。当同步状态由持有线程释放时,会把同步队列中的首节点中的线程唤醒,使其再次尝试获取同步状态。
同步队列中的结点用来保存获取同步状态失败的线程的线程引用、等待状态以及前驱结点和后继结点。
同步队列遵循FIFO,首节点是获取同步状态成功的节点,首节点线程在释放同步状态时,将会唤醒后继节点,而后继节点将会在获取同步状态成功时将自己设置为首节点。
27.1 AQS 对资源的共享方式
AQS定义两种资源共享方式
- Exclusive(独占):只有一个线程能执行,如ReentrantLock。又可分为公平锁和非公平锁:
- 公平锁:按照线程在队列中的排队顺序,先到者先拿到锁
- 非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的
- Share(共享):多个线程可同时执行,如Semaphore/CountDownLatch。Semaphore、CountDownLatch、 CyclicBarrier、ReadWriteLock 我们都会在后面讲到。
ReentrantReadWriteLock 可以看成是组合式,因为ReentrantReadWriteLock也就是读写锁允许多个线程同时对某一资源进行读。
不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源 state 的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。
27.2 AQS底层使用了模板方法模式
同步器的设计是基于模板方法模式的,如果需要自定义同步器一般的方式是这样(模板方法模式很经典的一个应用):
- 使用者继承AbstractQueuedSynchronizer并重写指定的方法。(这些重写方法很简单,无非是对于共享资源state的获取和释放)
- 将AQS组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的方法。
这和我们以往通过实现接口的方式有很大区别,这是模板方法模式很经典的一个运用。
AQS使用了模板方法模式,自定义同步器时需要重写下面几个AQS提供的模板方法:
isHeldExclusively()//该线程是否正在独占资源。只有用到condition才需要去实现它。
tryAcquire(int)//独占方式。尝试获取资源,成功则返回true,失败则返回false。
tryRelease(int)//独占方式。尝试释放资源,成功则返回true,失败则返回false。
tryAcquireShared(int)//共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
tryReleaseShared(int)//共享方式。尝试释放资源,成功则返回true,失败则返回false。
默认情况下,每个方法都抛出 UnsupportedOperationException。 这些方法的实现必须是内部线程安全的,并且通常应该简短而不是阻塞。AQS类中的其他方法都是final ,所以无法被其他类使用,只有这几个方法可以被其他类使用。
以ReentrantLock为例,state初始化为0,表示未锁定状态。A线程lock()时,会调用tryAcquire()独占该锁并将state+1。此后,其他线程再tryAcquire()时就会失败,直到A线程unlock()到state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证state是能回到零态的。
再以CountDownLatch以例,任务分为N个子线程去执行,state也初始化为N(注意N要与线程个数一致)。这N个子线程是并行执行的,每个子线程执行完后countDown()一次,state会CAS(Compare and Swap)减1。等到所有子线程都执行完后(即state=0),会unpark()主调用线程,然后主调用线程就会从await()函数返回,继续后余动作。
一般来说,自定义同步器要么是独占方法,要么是共享方式,他们也只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。但AQS也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock。
28 什么是可重入锁(ReentrantLock)?
ReentrantLock重入锁,是实现Lock接口的一个类,也是在实际编程中使用频率很高的一个锁,支持重入性,表示能够对共享资源能够重复加锁,即当前线程获取该锁再次获取不会被阻塞。
在java关键字synchronized隐式支持重入性,synchronized通过获取自增,释放自减的方式实现重入。与此同时,ReentrantLock还支持公平锁和非公平锁两种方式。
要想完完全全的弄懂ReentrantLock的话,主要也就是ReentrantLock同步语义的学习:
- 重入性的实现原理;
- 公平锁和非公平锁的实现。
面试的会问这两个的原理!!!
28.0 ReentrantLock的使用
28.0.1 同步执行,类似synchronized
private ReentrantLock lock = new ReentrantLock();
public void run() {
lock.lock();
try {
//do bussiness
} finally {
lock.unlock();
}
}
28.0.2 防止重复执行(忽略重复触发)
private ReentrantLock lock = new ReentrantLock();
public void run() {
if (lock.tryLock()) { //如果已经被lock,则立即返回false不会等待,达到忽略操作的效果
try {
//操作
} finally {
lock.unlock();
}
}
28.0.3 尝试等待执行
private ReentrantLock lock = new ReentrantLock();
public void run() {
try {
if (lock.tryLock(5, TimeUnit.SECONDS)) {
//如果已经被lock,尝试等待5s,看是否可以获得锁,如果5s后仍然无法获得锁则返回false继续执行
try {
//操作
} finally {
lock.unlock();
}
}
} catch (InterruptedException e) {
e.printStackTrace(); //当前线程被中断时(interrupt),会抛InterruptedException
}
}
28.0.3 可中断锁的同步执行
private ReentrantLock lock = new ReentrantLock();
public void run() {
lock.lockInterruptibly();
try {
//操作
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
28.1 重入性的实现原理
要想支持重入性,就要解决两个问题:
- 在线程获取锁的时候,如果已经获取锁的线程是当前线程的话则直接再次获取成功;
- 由于锁会被获取n次,那么只有锁在被释放同样的n次之后,该锁才算是完全释放成功。
ReentrantLock持有实现了AbstractQueuedSynchronizer的static内部类,而AbstractQueuedSynchronizer继承了AbstractOwnableSynchronizer,AbstractOwnableSynchronizer有个保存当前持有锁的线程的变量exclusiveOwnerThread。
所以,ReentrantLock是通过保存的持有锁的线程来判断获取锁的操作是重入的还是竞争的。
ReentrantLock支持两种锁:公平锁和非公平锁。
何谓公平性,是针对获取锁而言的,如果一个锁是公平的,那么锁的获取顺序就应该符合请求上的绝对时间顺序,满足FIFO。
28.2 ReentrantLock详解
ReentrantLock的类图如下:
默认是非公平锁NonfairSync,ReentrantLock的公平与否,可以通过它的构造函数来决定。
通过简介中的类图可以看到,Sync类是ReentrantLock自定义的同步组件,它是ReentrantLock里面的一个内部类,它继承自AQS,它有两个子类:公平锁FairSync和非公平锁NonfairSync。
28.2.1 ReentrantLock 获取锁
ReentrantLock的获取与释放锁操作都是委托给该同步组件来实现的。
下面我们来看一看非公平锁的lock()方法:
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread()); // 记录获取到独占锁的线程
else
acquire(1);
}
它首先会通过compareAndSetState(int, int)方法来尝试修改同步状态,如果修改成功则表示获取到了锁,然后调用setExclusiveOwnerThread(Thread)方法来设置获取到锁的线程。
如果同步状态修改失败,则表示没有获取到锁,lock()会需要调用acquire(int)方法,该方法定义在AQS中,如下:
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
tryAcquire(int)是子类需要重写的方法,在非公平锁中的实现如下:
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
final boolean nonfairTryAcquire(int acquires) {
// 获取当前线程
final Thread current = Thread.currentThread();
// 获取同步状态
int c = getState();
// 同步状态为0,表示没有线程获取锁
if (c == 0) {
// 尝试修改同步状态
if (compareAndSetState(0, acquires)) {
// 同步状态修改成功,获取到锁
setExclusiveOwnerThread(current);
return true;
}
}
// 同步状态不为0,表示已经有线程获取了锁,判断获取锁的线程是否为当前线程
else if (current == getExclusiveOwnerThread()) {
// 获取锁的线程是当前线程
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
// 获取锁的线程不是当前线程
return false;
}
nonfairTryAcquire(int)方法首先判断同步状态是否为0,如果是0,则表示该锁还没有被线程持有,然后通过CAS操作获取同步状态,如果修改成功,返回true。如果同步状态不为0,则表示该锁已经被线程持有,需要判断当前线程是否为获取锁的线程,如果是则获取锁,成功返回true。成功获取锁的线程再次获取该锁,只是增加了同步状态的值,这也就实现了可重入锁。
28.2.2 释放锁
成功获取锁的线程在完成业务逻辑之后,需要调用unlock()来释放锁:
public void unlock() {
sync.release(1);
}
unlock()调用NonfairSync类的release(int)方法释放锁,release(int)方法是定义在AQS中的方法:
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
unparkSuccessor(h)是通过LockSupport.unpark(s.thread);来使线程从阻塞状态恢复为就绪状态的
tryRelease(int)是子类需要实现的方法:
protected final boolean tryRelease(int releases) {
// 计算新的状态值
int c = getState() - releases;
// 判断当前线程是否是持有锁的线程,如果不是的话,抛出异常
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
// 新的状态值是否为0,若为0,则表示该锁已经完全释放了,其他线程可以获取同步状态了
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
// 更新状态值
setState(c);
return free;
}
如果该锁被获取n次,那么前(n-1)次tryRelease(int)方法必须返回false,只有同步状态完全释放了,才能返回true。可以看到,该方法将同步状态是否为0作为最终释放的条件,当状态为0时,将占有线程设为null,并返回true,表示释放成功。
28.3 公平锁与非公平锁的实现原理
这样回答即可:
非公平锁会无视正在等待锁资源的队列(同步队列)里面是否有其它前置节点,而直接尝试一次获取,若不成功,则还是会进入AQS的CLH等待队列,然后阻塞,顺序等待唤醒,获取
公平锁的实现机理在于每次有线程来抢占锁的时候,都会检查一遍同步队列有没有其它前置节点。
ReentrantLock的使用一般从lock()或者tryLock()开始,默认一般使用非公平锁,它的效率和吞吐量都比公平锁高的多。
ReentrantLock获取锁是通过同步器来获取的:
public void lock() {
sync.lock();
}
ReentrantLock同步器有两种,默认为NonfairSync
public ReentrantLock() {
sync = new NonfairSync();
}
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
非公平锁NonfairSync的lock()实现如下:
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
final boolean nonfairTryAcquire(int acquires) {
// 不会考虑同步队列里是否有其它前置节点
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
公平锁FairSync的lock()实现如下:
final void lock() {
acquire(1);
}
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() && // hasQueuedPredecessors()用于检查同步队列里是否有其它前置节点。
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
从上可知,ReentrantLock 的公平锁和非公平锁都委托了 AbstractQueuedSynchronizer#acquire 去请求获取。
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
tryAcquire 是一个抽象方法,是公平与非公平的实现原理所在,非公平锁NonfairSync和公平锁FairSync的实现不同,主要是:
非公平锁,lock的时候,会无视正在等待锁资源的队列(同步队列)里面是否有成员,而直接尝试一次获取,若不成功,则还是会进入AQS的CLH等待队列,然后阻塞,顺序等待唤醒,获取。
公平锁,lock的时候,则不能无视正在等待锁资源的队列(同步队列)里面的成员。
addWaiter 是将当前线程结点加入同步队列之中。
28.4 同步队列的组成
AQS 中的队列是由 Node 节点组成的双向链表实现的。
如果 tryAcquire(arg) 获取锁失败,则需要用 addWaiter(Node.EXCLUSIVE) 将当前线程写入队列中。
写入之前需要将当前线程包装为一个 Node 对象(addWaiter(Node.EXCLUSIVE))。
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
// Try the fast path of enq; backup to full enq on failure
Node pred = tail;
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
enq(node);
return node;
}
首先判断队列是否为空,不为空时则将封装好的 Node 利用 CAS 写入队尾,如果出现并发写入失败就需要调用 enq(node); 来写入了。
接下来看看同步队列中的节点!!!
- 线程A获取到锁,但是未释放
- 线程B尝试获取锁,但是锁未被释放,因此进入等待队列
- 线程C尝试获取锁,但是锁未被释放,因此进入等待队列
28.5 ReentrantLock线程未获取到锁进入等待队列的原理
使用tryacquire获取锁却未获取到锁时,则将当前线程添加到等到队列
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
当前线程的前驱不是head节点(空节点,不存储线程相关信息),且获取锁失败(tryAcquire),则判断是否需要阻塞当前线程 即shouldParkAfterFailedAcquire()
阻塞线程的操作在parkAndCheckInterrupt()
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this);
return Thread.interrupted();
}
通过 LockSupport.park(this)来阻塞线程!!!通过 LockSupport.unpark(thread)来恢复线程为就绪状态!!!
29 AQS的state有什么作用?
AQS维护了一个volatile int state(代表共享资源),state的访问方式有三种:
- getState
- setState
- compareAndSetState
自定义同步器在实现时只需要实现共享资源state的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。
以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减1。等到所有子线程都执行完后(即state=0),会unpark主调用线程,然后主调用线程就会从await函数返回,继续后余动作。
29 Reentrant的非公平锁不考虑等待锁资源的队列里面是否有成员,为什么还需要AQS来实现锁?
非公平锁,lock的时候,会无视正在等待锁资源的队列里面是否有成员,而直接尝试一次获取,若不成功,则还是会进入AQS的CLH等待队列,然后阻塞,顺序等待唤醒,获取。
公平锁,lock的时候,则不能无视正在等待锁资源的队列里面的成员。
29 ReentrantLock中如何唤醒等待队列中的元素
当前拥有锁的线程释放锁之后, 且非公平锁没有线程抢占,就开始线程唤醒的流程。
通过tryRelease释放锁成功,调用LockSupport.unpark(s.thread); 终止线程阻塞。
public void unlock() {
sync.release(1);
}
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
private void unparkSuccessor(Node node) {
// 强行回写将被唤醒线程的状态
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);
Node s = node.next;
// s为h的下一个Node, 一般情况下都是非Null的
if (s == null || s.waitStatus > 0) {
s = null;
// 否则按照FIFO原则寻找最先入队列的并且没有被Cancel的Node
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
// 再唤醒它
if (s != null)
LockSupport.unpark(s.thread); // 调用native方法
}
29 ReadWriteLock 是什么
首先明确一下,不是说 ReentrantLock 不好,只是 ReentrantLock 某些时候有局限。如果使用 ReentrantLock,可能本身是为了防止线程 A 在写数据、线程 B 在读数据造成的数据不一致,但这样,如果线程 C 在读数据、线程 D 也在读数据,读数据是不会改变数据的,没有必要加锁,但是还是加锁了,降低了程序的性能。因为这个,才诞生了读写锁 ReadWriteLock。
ReadWriteLock 是一个读写锁接口,读写锁是用来提升并发程序性能的锁分离技术,ReentrantReadWriteLock 是 ReadWriteLock 接口的一个具体实现,实现了读写的分离,读锁是共享的,写锁是独占的,读和读之间不会互斥,读和写、写和读、写和写之间才会互斥,提升了读写的性能。
而读写锁有以下三个重要的特性:
公平选择性:支持非公平(默认)和公平的锁获取方式,吞吐量还是非公平优于公平。
重进入:读锁和写锁都支持线程重进入。
锁降级:遵循获取写锁、获取读锁再释放写锁的次序,写锁能够降级成为读锁。
30 读写锁ReentrantReadWriteLock源码分析
Java常见的多是排他锁(如Mutex和ReentrantLock),这些锁在同一时刻只允许一个线程进行访问,而读写锁在同一时刻可以允许多个读线程访问,但是在写线程访问时,所有的读线程和其他写线程均被阻塞。读写锁维护了一对锁,一个读锁和一个写锁,通过分离读锁和写锁,使得并发性相比一般的排他锁有了很大提升。(似乎同一线程在获取写锁后可以获取读锁)
一般情况下,读写锁的性能都会比排它锁好,因为大多数场景读是多于写的。在读多于写的情况下,读写锁能够提供比排它锁更好的并发性和吞吐量。
Java并发包提供读写锁的实现是ReentrantReadWriteLock,它提供的特性如下:
特性 | 说明 |
---|---|
公平性选择 | 支持非公平(默认)和公平的锁获取方式,吞吐量还是非公平优于公平 |
重进入 | 读锁和写锁都支持线程重进入 |
锁降级 | 遵循获取写锁、获取读锁再释放写锁的次序,写锁能够降级成为读锁 |
ReentrantReadWriteLock允许多个读线程同时访问,但不允许写线程和读线程、写线程和写线程同时访问。
ReentrantReadWriteLock的定义:
public class ReentrantReadWriteLock
implements ReadWriteLock, java.io.Serializable {
private final ReentrantReadWriteLock.ReadLock readerLock;
/** Inner class providing writelock */
private final ReentrantReadWriteLock.WriteLock writerLock;
/** Performs all synchronization mechanics */
final Sync sync;
/** 使用默认(非公平)的排序属性创建一个新的 ReentrantReadWriteLock */
public ReentrantReadWriteLock() {
this(false);
}
读写锁内部类关系如下图所示:
30.1 读写状态设计
读写锁同样依赖自定义同步器(AQS)来实现同步功能,而读写状态就是其同步器的同步状态。回想ReentrantLock中自定义同步器的实现,同步状态表示锁被一个线程重复获取的次数,而读写锁的自定义同步器需要在同步状态(一个整型变量)上维护多个读线程和一个写线程的状态.怎么办啊?
简单理解就是“掰开”用。由于读锁可以同时有多个,肯定不能再用辦成两份用的方法来处理了,但我们有 ThreadLocal,可以把线程重入读锁的次数作为值存在 ThreadLocal 里,就是上面sync类的HoldCounter。
如果在一个整型变量上维护多种状态,就一定需要“按位切割使用”这个变量,读写锁是将变量切分成了两个部分,高16位表示读,低16位表示写,划分方式如图所示:
读写锁是通过位运算迅速确定读和写各自的状态!
假设当前同步状态值为S,写状态等于S&0x0000FFFF(将高16位全部抹去),读状态等于S>>>16(无符号补0右移16位)。当写状态增加1时,等于S+1,当读状态增加1时,等于S+(1<<16),也就是S+0x00010000。
31 Condition等待通知机制
Condition主要是为了在J.U.C框架中提供和Java传统的监视器风格的wait,notify和notifyAll方法类似的功能。
Condition是在java 1.5中才出现的,它用来替代传统的Object的wait()、notify()实现线程间的协作,相比使用Object的wait()、notify(),使用Condition的await()、signal()这种方式实现线程间协作更加安全和高效。
Condition必须要配合锁一起使用,因为对共享状态变量的访问发生在多线程环境下。一个Condition的实例必须与一个Lock绑定,因此Condition一般都是作为Lock的内部实现。
31.0 Condition简单使用
Condition的使用方式比较简单,需要注意在调用方法前获取锁:
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class ConditionUseCase {
Lock lock = new ReentrantLock();
Condition condition = lock.newCondition();
public void conditionWait() throws InterruptedException {
lock.lock();
try {
condition.await();
} finally {
lock.unlock();
}
}
public void conditionSignal() throws InterruptedException {
lock.lock();
try {
condition.signal();
} finally {
lock.unlock();
}
}
}
一般都会将Condition对象作为成员变量。当调用await()方法后,当前线程会释放锁并在此等待,而其他线程调用Condition对象的signal()方法,通知当前线程后,当前线程才从await()方法返回,并且在返回前已经获取了锁。
31.1 Object的监视器方法与Condition接口的对比
任意一个Java对象,都拥有一组监视器方法(定义在java.lang.Object类上),主要包括wait()、wait(long)、notify()、notifyAll()方法,这些方法与synchronized关键字配合,可以实现等待/通知模式。
Condition接口也提供了类似Object的监视器方法,与Lock配合可以实现等待/通知模式,但是这两者在使用方式以及功能特性上还是有区别的。
Object的监视器方法与Condition接口对比如下:
主要方法:
await() :造成当前线程在接到信号或被中断之前一直处于等待状态。
signal() :唤醒一个等待线程。该线程从等待方法返回前必须获得与Condition相关的锁。
32 Condition源码分析
JUC中Condition接口的实现类只有ConditionObject。
ConditionObject是同步器AbstractQueuedSynchronizer的内部类,因为Condition的操作需要获取相关联的锁,所以作为同步器的内部类也较为合理。
每个ConditionObject对象都包含着一个队列(以下称为等待队列),该队列是Condition对象实现等待/通知功能的关键。
下面将分析Condition的实现,主要包括:等待队列、等待和通知,下面提到的Condition如果不加说明均指的ConditionObject。
32.1 等待队列
等待队列是一个FIFO的队列,在队列中的每个节点都包含了一个线程引用(下图2处),该线程就是在Condition对象上等待的线程,如果一个线程调用了Condition.await()方法,那么该线程将会释放锁、构造成节点加入等待队列并进入等待状态。
一个Condition包含一个等待队列,Condition拥有首节点(firstWaiter)和尾节点(lastWaiter)。当前线程调用Condition.await()方法,将会以当前线程构造节点,并将节点从尾部加入等待队列,等待队列的基本结构:
新增节点只需要将原有的尾节点nextWaiter指向它,并且更新尾节点即可。上述节点引用更新的过程并没有使用CAS保证,原因在于调用await()方法的线程必定是获取了锁的线程,也就是说该过程是由锁来保证线程安全的。
在Object的监视器模型上,一个对象拥有一个同步队列和等待队列,而并发包中的Lock(更确切地说是同步器)拥有一个同步队列和多个等待队列,其对应关系如下:
Condition的实现是同步器的内部类,因此每个Condition实例都能够访问同步器提供的方法,相当于每个Condition都拥有所属同步器的引用
32.2 等待
调用等待方法wait()的线程是成功获取了锁的线程,也就是同步队列中的首节点,该方法会将当前线程构造成节点并加入等待队列中并释放锁(同步状态),同时线程状态变为等待状态,唤醒同步队列中的后继节点,然后当前线程会进入等待状态。当从await()方法返回时,当前线程一定获取了Condition相关联的锁。
当等待队列中的节点被唤醒的时候,则唤醒节点的线程开始尝试获取同步状态。如果不是通过其他线程调用Condition.signal()方法唤醒,而是对等待线程进行中断,则会抛出InterruptedException异常信息。
如果从队列(同步队列和等待队列)的角度看await()方法,当调用await()方法时,相当于同步队列的首节点(获取了锁的节点)移动到Condition的等待队列中。
public final void await() throws InterruptedException {
//判断中断
if (Thread.interrupted())
throw new InterruptedException();
//将当前线程封装成Node节点后入等待队列
Node node = addConditionWaiter();
//释放拿到的同步状态
int savedState = fullyRelease(node);
int interruptMode = 0;
while (!isOnSyncQueue(node)) {
//当线程不再同步队列中将其阻塞,置为wait状态
LockSupport.park(this);
if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
break;
}
//在同步队列中排队获取锁
if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
interruptMode = REINTERRUPT;
//处理同步状态
if (node.nextWaiter != null) // clean up if cancelled
unlinkCancelledWaiters();
if (interruptMode != 0)
reportInterruptAfterWait(interruptMode);
}
32.3 通知
调用Condition的signal()方法,将会唤醒在等待队列中等待最长时间的节点(条件队列里的首节点),在唤醒节点前,会将节点移到同步队列中。
在调用signal()方法之前必须先判断是否获取到了锁。接着获取等待队列的首节点,将其移动到同步队列并且利用LockSupport唤醒节点中的线程。
被唤醒的线程将从await方法中的while循环中退出。随后加入到同步状态的竞争当中去。成功获取到竞争的线程则会返回到await方法之前的状态。
也就是说,通知后的线程还是要去竞争同步状态,成功后才会再次执行!
33 LockSupport详解
LockSupport 和 CAS 是Java并发包(JUC)中很多并发工具控制机制的基础,它们底层其实都是依赖Unsafe实现。Unsage类里的方法都是native方法!!!
33.1 简介
在JUC中当需要阻塞或唤醒一个线程的时候,都会使用LockSupport工具类来完成相应工作。
LockSupport是JDK中比较底层的类,用来创建锁和其他同步工具类的基本线程阻塞原语。
LockSupport定义了一组的公共静态方法,这些方法提供了最基本的线程阻塞和唤醒功能,而LockSupport也成为构建同步组件的基础工具。
LockSupport定义了一组以park开头的方法用来阻塞当前线程,以及unpark(Thread thread)方法来唤醒一个被阻塞的线程。Park有停车的意思,假设线程为车辆,那么park方法代表着停车,而unpark方法则是指车辆启动离开。
LockSupport提供的阻塞和唤醒方法如下:
Unsage类里的方法都是native方法!!!
park 方法还可以在其他任何时间“毫无理由”地返回,因此通常必须在重新检查返回条件的循环里调用此方法。从这个意义上说,park 是“忙碌等待”的一种优化,它不会浪费这么多的时间进行自旋,但是必须将它与 unpark 配对使用才更高效。
33.2 LockSupport的使用
在AQS(AbstractQueuedSynchronizer)的源码中就用到了LockSupport类。
以下方法中就有用到LockSupport 的静态方法:
- unparkSuccessor()
- parkAndCheckInterrupt()
- doAcquireSharedNanos()
- transferForSignal()
- await()
- awaitNanos()
- awaitUninterruptibly()
三、线程池
1 什么是线程池?
池化技术相比大家已经屡见不鲜了,线程池、数据库连接池、Http 连接池等等都是对这个思想的应用。池化技术的思想主要是为了减少每次获取资源的消耗,提高对资源的利用率。
线程池顾名思义就是事先创建若干个可执行的线程放入一个池(容器)中,需要的时候从池中获取线程不用自行创建,使用完毕不需要销毁线程而是放回池中,从而减少创建和销毁线程对象的开销。
1 为什么需要线程池
Java线程的创建非常昂贵,需要JVM和OS(操作系统)配合完成大量的工作:
- 必须为线程堆栈分配和初始化大量内存块,其中包含至少1MB的栈内存。
- 需要进行系统调用,以便在OS(操作系统)中创建和注册本地线程。
Java高并发应用频繁创建和销毁线程的操作是非常低效的,而且是不被编程规范所允许的。如何降低Java线程的创建成本?必须使用到线程池。线程池主要解决了以下两个问题:
- 提升性能:线程池能独立负责线程的创建、维护和分配。在执行大量异步任务时,可以不需要自己创建线程,而是将任务交给线程池去调度。线程池能尽可能使用空闲的线程去执行异步任务,最大限度地对已经创建的线程进行复用,使得性能提升明显。
- 线程管理:每个Java线程池会保持一些基本的线程统计信息,例如完成的任务数量、空闲时间等,以便对线程进行有效管理,使得能对所接收到的异步任务进行高效调度。
2 线程池的优势
- 降低资源消耗:重用存在的线程,减少对象创建销毁的开销。
- 提高系统响应速度。可有效的控制最大并发线程数,提高系统资源的使用率,同时避免过多资源竞争,避免堵塞。当任务到达时,任务可以不需要的等到线程创建就能立即执行。
- 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
- 附加功能:提供定时执行、定期执行、单线程、并发数控制等功能。
2.0 如果 corePoolSize = 0 且 阻塞队列是无界的。线程池将如何工作?
结论: 如果线程池处于 Running状态,则检查工作线程(worker)是否为0。如果为0,则创建新的线程来处理任务。
解释如下:
execute() 中有三处调用了 addWork() 我们逐一分析。
- 第一次,条件 if (workerCountOf© < corePoolSize) 这个很好理解,工作线程数少于核心线程数,提交任务。所以 addWorker(command, true)。
- 第二次,如果 workerCountOf(recheck) == 0 如果worker的数量为0,那就 addWorker(null,false)。为什么这里是 null ?之前已经把 command 提交到阻塞队列了 workQueue.offer(command) 。所以提交一个空线程,直接从阻塞队列里面取就可以了。
- 第三次,如果线程池没有 RUNNING 或者 offer 阻塞队列失败,addWorker(command,false),很好理解,对应的就是,阻塞队列满了,将任务提交到,非核心线程池。与最大线程池比较。
2.1 线程池被创建后里面有线程吗?
线程池被创建后如果没有任务过来,里面是不会有线程的。可以进行手动预热。
2.2 线程池线程是怎么回收的?
线程池中线程的销毁依赖JVM自动的回收,线程池做的工作是根据当前线程池的状态维护一定数量的线程引用,防止这部分线程被JVM回收,当线程池决定哪些线程需要回收时,只需要将其引用消除即可。Worker被创建出来后,就会不断地进行轮询,然后获取任务去执行,核心线程可以无限等待获取任务,非核心线程要限时获取任务。当Worker无法获取到任务,也就是获取的任务为空时,循环会结束,Worker会主动消除自身在线程池内的引用。 详情见2.7
2.3 JDK线程池具体是如何区分核心线程和非核心线程?
线程池不区分核心线程和非核心线程,只是根据数量来保留核心线程数量大小的线程。
分情况讨论,如果allowCoreThreadTimeOut为true,那么对于线程池而言,核心线程和非核心线程最终的处理是一模一样的,没有差别;allowCoreThreadTimeOut默认为false,则是根据工作线程数量是否大于corePoolSize属性,大于则为非核心线程,否则为核心线程。
2.4 核心线程真的可以一直存活吗?
核心线程数默认是不会被回收一直存活的。不过,如果allowCoreThreadTimeOut为true,则工作队列为空核心线程一直获取不到任务对象时,核心线程也会像非核心线程一样消亡。
是因为ThreadPoolExecutor#getTask中的一段代码如下:
// 下面这段代码很有意思,这个就是为啥非核心线程会存活keepAliveTime的原因所在,而核心线程没有设置allowCoreThreadTimeOut的情况下会一直存活!!
try {
Runnable r = timed ?
workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
workQueue.take();
if (r != null)
return r;
timedOut = true;
} catch (InterruptedException retry) {
timedOut = false;
}
2.5 非核心线程又为什么只能存在一段时间?
如果Worker对象被判断为非核心线程(当前线程数大于核心线程数时,当前线程就是非核心线程),从workQueue工作任务队列中获取任务线程时会有时间限制(ThreadPoolExecutor#getTask中的workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS)中会阻塞keepAliveTime时长),如果超出时间还没有获取到任务线程,Worker对象将会消亡;但是allowCoreThreadTimeOut为false核心线程使用的是阻塞获取,将永远不会返回null,因此核心线程这种情况可以一直存在。
是因为ThreadPoolExecutor#getTask中的一段代码如下:
// 下面这段代码很有意思,这个就是为啥非核心线程会存活keepAliveTime的原因所在,而核心线程没有设置allowCoreThreadTimeOut的情况下会一直存活!!
try {
Runnable r = timed ?
workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
workQueue.take();
if (r != null)
return r;
timedOut = true;
} catch (InterruptedException retry) {
timedOut = false;
}
2.6 非核心线程的存活时间由什么控制?
由keepAliveTime属性,非核心线程在这个时间内为获取到任务线程,将会消亡(allowCoreThreadTimeOut为true时核心线程存在的时间也是由这个属性控制的)。
2.7 当任务执行完毕后,线程池是如何回收工作线程,线程是怎么销毁的?
结论:getTask()是关键,在不考虑异常的场景下,返回null,就表示退出循环,结束线程。下一步,就得看看,什么情况下getTask()会返回null。
工作线程启动后,就进入runWorker(Worker w)方法。
final void runWorker(Worker w) {
Runnable task = w.firstTask;
w.firstTask = null;
boolean completedAbruptly = true;//是否“突然完成”,非正常完成
try {
while (task != null || (task = getTask()) != null) {
w.lock();
clearInterruptsForTaskRun();
try {
beforeExecute(w.thread, task);
Throwable thrown = null;
try {
task.run();
} catch (RuntimeException x) {
thrown = x; throw x;
} catch (Error x) {
thrown = x; throw x;
} catch (Throwable x) {
thrown = x; throw new Error(x);
} finally {
afterExecute(task, thrown);
}
} finally {
task = null;
w.completedTasks++;
w.unlock();
}
}
completedAbruptly = false;
} finally {
processWorkerExit(w, completedAbruptly);
}
}
runWorker运行期间,将不断调用getTask()从任务队列中取任务来执行;同时runWorker也会管理线程的中断状态。
private Runnable getTask() {
boolean timedOut = false; // Did the last poll() time out?
/**
* 外层循环
* 用于检查线程池状态和工作队列是否为空
*/
retry:
for (;;) {
int c = ctl.get();
int rs = runStateOf(c);
// 调用了shutdownNow()或调用了shutdown()且workQueue为空,返回true
if (rs >= SHUTDOWN && (rs >= STOP || workQueue.isEmpty())) {
decrementWorkerCount();
return null;
}
boolean timed; // Are workers subject to culling?
/**
* 内层循环
* 用于检测工作线程数量和获取task的超时状态
*/
for (;;) {
int wc = workerCountOf(c);
timed = allowCoreThreadTimeOut || wc > corePoolSize;
if (wc <= maximumPoolSize && ! (timedOut && timed))
break;
if (compareAndDecrementWorkerCount(c))
return null;
c = ctl.get(); // Re-read ctl
if (runStateOf(c) != rs)
continue retry;
// else CAS failed due to workerCount change; retry inner loop
}
// 下面这段代码很有意思,这个就是为啥非核心线程会存活keepAliveTime的原因所在,而核心线程没有设置allowCoreThreadTimeOut的情况下会一直存活!!
try {
Runnable r = timed ?
workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
workQueue.take();
if (r != null)
return r;
timedOut = true;
} catch (InterruptedException retry) {
timedOut = false;
}
}
}
从图中可知:当线程getTask()返回null,就会被回收。
分两种场景:
- 未调用shutdown() ,RUNNING状态下全部任务执行完成的场景
线程数量大于corePoolSize,线程超时阻塞,超时唤醒后CAS减少工作线程数,如果CAS成功,返回null,线程回收。否则进入下一次循环。当工作者线程数量小于等于corePoolSize,就可以一直阻塞了。
- 调用shutdown() ,全部任务执行完成的场景
shutdown() 会向所有线程发出中断信号,这时有两种可能:
- 1)所有线程都在阻塞
中断唤醒,进入循环,都符合第一个if判断条件,都返回null,所有线程回收。
- 2)任务还没有完全执行完
至少会有一条线程被回收。在processWorkerExit(Worker w, boolean completedAbruptly)方法里会调用tryTerminate(),向任意空闲线程发出中断信号。所有被阻塞的线程,最终都会被一个个唤醒,回收。
2.8 线程池中的线程发生了异常是怎么处理的?
当线程出现未捕获异常的时候就执行不下去了,留给它的就是垃圾回收了。
2.9 线程池中的线程退出(销毁)是怎么处理的?
线程退出有两种可能:
- 任务队列为空,非核心线程获取不到任务,则非核心线程会直接退出(销毁)。
- 线程发生了异常,不管是核心线程还是非核心线程,都会退出(销毁),不过如果当前的线程数小于设定的核心线程数且核心线程一直存活(默认情况下是一直存活的),则会重新建一个线程(Worker类)。
代码详解如下:
private void processWorkerExit(Worker w, boolean completedAbruptly) {
/**
* 如果是突然终止,工作线程数减1
* 如果不是突然终止,在getTask()中已经减1
*/
if (completedAbruptly)
decrementWorkerCount();
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();//锁定线程池
try {
completedTaskCount += w.completedTasks;//汇总完成的任务数量
workers.remove(w);//移除工作线程
} finally {
mainLock.unlock();
}
tryTerminate();//尝试终止线程池
int c = ctl.get();
//状态是running、shutdown,即tryTerminate()没有成功终止线程池
if (runStateLessThan(c, STOP)) {
if (!completedAbruptly) {
int min = allowCoreThreadTimeOut ? 0 : corePoolSize;
//任务队列中仍然有任务未执行,需至少保证有一个工作线程
if (min == 0 && ! workQueue.isEmpty())
min = 1;
/**
* allowCoreThreadTimeOut为false则需要保证线程池中至少有corePoolSize数量的工作线程
*/
if (workerCountOf(c) >= min)
return;
}
//添加一个没有firstTask的工作线程
addWorker(null, false);
}
}
3 线程池都有哪些状态?
- RUNNING:这是最正常的状态,接受新的任务,处理等待队列中的任务。
- SHUTDOWN:不接受新的任务提交,但是会继续处理等待队列中的任务。
- STOP:不接受新的任务提交,不再处理等待队列中的任务,中断正在执行任务的线程。
- TIDYING:所有的任务都销毁了,workCount 为 0,线程池的状态在转换为 TIDYING 状态时,会执行钩子方法 terminated()。
- TERMINATED:terminated()方法结束后,线程池的状态就会变成这个。
3 怎么创建线程池?
无论是创建何种类型线程池(FixedThreadPool、CachedThreadPool …),均会调用ThreadPoolExecutor构造函数。
ThreadPoolExecutor作为java.util.concurrent包对外提供基础实现,以内部线程池的形式对外提供管理任务执行,线程调度,线程池管理等等服务; ThreadPoolExecutor() 是最原始的线程池创建。
ThreadPoolExecutor中包含最全参数的构造函数:
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
corePoolSize(线程池基本大小):当向线程池提交一个任务时,若线程池已创建的线程数小于corePoolSize,即便此时存在空闲线程,也会通过创建一个新线程来执行该任务,直到已创建的线程数大于或等于corePoolSize时,(除了利用提交新任务来创建和启动线程(按需构造),也可以通过 prestartCoreThread() 或 prestartAllCoreThreads() 方法来提前启动线程池中的基本线程。)
maximumPoolSize(线程池最大大小): 线程池所允许的最大线程个数。当队列满了,且已创建的线程数小于maximumPoolSize,则线程池会创建新的线程来执行任务。另外,对于无界队列,可忽略该参数。
keepAliveTime(线程存活保持时间) 当线程池中线程数大于核心线程数时,线程的空闲时间如果超过线程存活时间,那么这个线程就会被销毁,直到线程池中的线程数小于等于核心线程数。
workQueue(任务队列): 用于传输和保存等待执行任务的阻塞队列。
threadFactory(线程工厂): 用于创建新线程。threadFactory创建的线程也是采用new Thread()方式,threadFactory创建的线程名都具有统一的风格:pool-m-thread-n(m为线程池的编号,n为线程池内的线程编号)。
handler(线程饱和策略): 当线程池和队列都满了,再加入线程会执行此策略。
4 线程池状态
线程池在生命周期的运行状态控制由,线程池有下面五种状态:
- RUNNING(正在运行): 接收新的任务,并处理队列中的任务。
- SHUTDOWN(关闭): 不在接收新的任务,但是会处理队列中的任务。
- STOP(停止): 不在接收新的任务,也不会处理队列中的任务,并且打断正在执行的任务。
- TIDYING(整理): 所有的任务被终止,workerCount为0.转到TIDYING状态的线程将执行terminated钩子函数。
- TERMINATED(终止): terminated方法执行完成。
4 线程池的阻塞队列包含哪几种选择?
重点是前四种!!!
- ArrayBlockingQueue: 是一个有边界的阻塞队列,它的内部实现是一个数组。它的容量在初始化时就确定不变。由数组实现的有界阻塞队列,该队列按照 FIFO 对元素进行排序。维护两个整形变量,标识队列头尾在数组中的位置,在生产者放入和消费者获取数据共用一个锁对象,意味着两者无法真正的并行运行,性能较低。
- LinkedBlockingQueue: 由链表组成的有界阻塞队列,如果不指定大小,默认使用 Integer.MAX_VALUE 作为队列大小,该队列按照 FIFO 对元素进行排序,对生产者和消费者分别维护了独立的锁来控制数据同步,意味着该队列有着更高的并发性能。强烈建议指定大小,当超过大小时,会阻塞。
- PriorityBlockingQueue: 是一个没有边界的队列,所有插入到PriorityBlockingQueue的对象必须实现java.lang.Comparable接口,队列优先级的排序就是按照我们对这个接口的实现来定义的。
- SynchronousQueue: 队列内部仅允许容纳一个元素。当一个线程插入一个元素后会被阻塞,除非这个元素被另一个线程消费。不存储元素的阻塞队列,无容量,可以设置公平或非公平模式,插入操作必须等待获取操作移除元素,反之亦然。
- DelayQueue: 支持延时获取元素的无界阻塞队列,创建元素时可以指定多久之后才能从队列中获取元素,常用于缓存系统或定时任务调度系统。
- LinkedTransferQueue:一个由链表结构组成的无界阻塞队列,与LinkedBlockingQueue相比多了transfer和tryTranfer方法,该方法在有消费者等待接收元素时会立即将元素传递给消费者。
- LinkedBlockingDeque:一个由链表结构组成的双端阻塞队列,可以从队列的两端插入和删除元素。
5 ThreadPoolExecutor的饱和策略
如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任时,ThreadPoolTaskExecutor 定义一些策略:
- ThreadPoolExecutor.AbortPolicy: 抛出 RejectedExecutionException来拒绝新任务的处理。
- ThreadPoolExecutor.CallerRunsPolicy: 调用执行自己的线程运行任务。您不会任务请求。但是这种策略会降低对于新任务提交速度,影响程序的整体性能。另外,这个策略喜欢增加队列容量。如果您的应用程序可以承受此延迟并且你不能任务丢弃任何一个任务请求的话,你可以选择这个策略。
- ThreadPoolExecutor.DiscardPolicy: 不处理新任务,直接丢弃掉,不会抛出异常。
- ThreadPoolExecutor.DiscardOldestPolicy: 此策略将丢弃最早的未处理的任务请求。
在默认情况下,ThreadPoolExecutor 将抛出 RejectedExecutionException 来拒绝新来的任务 ,这代表你将丢失对这个任务的处理。
6 线程池的工作原理
- 初始化线程池: 在程序启动时,线程池会根据预设的参数,创建一定数量的线程,并将它们放入线程池中。
- 任务提交: 当程序需要执行一个任务时,它会向线程池提交一个任务请求。任务可以是一个函数、一个方法或者一个类的实例等。
- 线程获取任务: 线程池中的空闲线程会竞争获取任务,一旦有空闲线程获取到任务,它就会从线程池中移除,开始执行任务。
- 任务执行: 线程执行任务,直到任务完成或者出现异常。
- 线程返回线程池: 任务执行完毕后,线程将会把自己重新放回线程池中,以便下一次任务执行时可以复用。
- 线程池维护: 线程池需要定期检查当前线程数是否达到预设的最大值,如果达到了,则新提交的任务需要等待,直到线程池中有空闲线程可用。
线程池刚创建时,里面没有一个线程。任务队列是作为参数传进来的。不过,就算队列里面有任务,线程池也不会马上执行它们。
当调用execute()方法添加一个任务时,线程池会做如下判断:
- 如果正在运行的线程数小于corePoolSize,那么马上创建线程运行这个任务。
- 如果正在运行的线程数大于或者等于corePoolSize,那么将这个任务放入队列。
- 如果这个时候队列满了,而且正在运行的线程数量小于maximumPoolSize,那么还是要创建线程运行这个任务。
- 如果队列满了,而且正在运行的线程数量大于或等于 maximumPoolSize,那么线程池会抛出异常,告诉调用者“我不能再接受任务了”
当一个线程完成任务时,它会从队列中取下一个任务来执行。
当一个线程无事可做,超过一定的时间(keepAliveTime)时,线程池会判断,如果当前运行的线程数大于corePoolSize时,那么这个线程会被停用掉,所以线程池的所有任务完成后,它最终会收缩到corePoolSize的大小。
execute()时的判断逻辑如下:
简单来说就是优先核心线程,其次等待队列,最后非核心线程。
接下来看看execute()的源码:
public void execute(Runnable command) {
if (command == null)
throw new NullPointerException();
int c = ctl.get();
//如果线程数小于基本线程数,则创建线程并执行当前任务
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true))
return;
c = ctl.get();
}
//如果线程数大于基本线程数或创建失败,则将当前任务放到工作队列中。
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
if (! isRunning(recheck) && remove(command))
reject(command);
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
//如果线程池不处于运行或者任务无法放入队列,并且当前线程数小于最大允许的线程数量,则创建一个线程执行任务,如果创建失败则执行拒绝策略
else if (!addWorker(command, false))
//抛出RejectedExecutionException
reject(command);
}
从上图可以把execute方法主要分三个步骤:
- 首先如果当前工作线程数小于核心线程,则调用addWorker(command, true)方法创建核心线程执行任务。
- 其次如果当前线程大于核心线程数则判断等待队列是否已满,如果没有满则添加任务到等待队列中去;如果工作线程数量为0则调用addWorker(null, false)方法创建非核心线程,并从等待队列中拉取任务执行。
- 最后如果队列已满则会调用addWorker(command, false)方法创建一个非核心线程执行任务。如果创建失败则会拒绝任务。
简单来说就是优先核心线程,其次等待队列,最后非核心线程。
6 ctl
要了解线程池,我们首先要了解的线程池里面的状态控制的参数 ctl。
- 线程池的ctl是一个原子的 AtomicInteger。
- 这个ctl包含两个参数 :
- workerCount 激活的线程数
- runState 当前线程池的状态
它的低29位用于存放当前的线程数, 因此一个线程池在理论上最大的线程数是 536870911; 高 3 位是用于表示当前线程池的状态, 其中高三位的值和状态对应如下: - 111: RUNNING
- 000: SHUTDOWN
- 001: STOP
- 010: TIDYING
- 110: TERMINATED
为了能够使用 ctl 线程池提供了三个方法:
// Packing and unpacking ctl
// 获取线程池的状态
private static int runStateOf(int c) { return c & ~CAPACITY; }
// 获取线程池的工作线程数
private static int workerCountOf(int c) { return c & CAPACITY; }
// 根据工作线程数和线程池状态获取 ctl
private static int ctlOf(int rs, int wc) { return rs | wc; }
6 addWorker方法详解
从上面可以看到execute中最关键的就是addWorker方法,它接受两个参数:
- 第一个参数是要执行的任务,如果为null那么会从等待队列中拉取任务;
- 第二个参数是表示是否核心线程,用来控制addWorker方法流程的;
addWorker()源码比较长,看起来比较唬人,其实就做了两件事:
- 1)采用循环CAS操作来将线程数加1;
- 2)新建一个线程并启用。
addWorker方法执行过程:
- ①首先获取当前线程数然后进行循环校验
- ②判断线程池的状态(是否开启、是否处于SHUTDOWN或STOP之后的状态)没达到RUNNING状态会直接返回
- ③然后尝试对请求活动数+1
- ④开始创建工作线程worker
- ⑤创建时会获取线程池的ReentrantLock主锁,避免在添加和启动线程时干扰
- ⑥真正地增加Worker线程,并且更新相关记录信息,如当前运行期间的最大并发任务个数
- ⑥做完这些操作之后进行解锁,然后启动这个新的worker线程,t.start()
- ⑦如果线程启动失败,需要把工作线程数(活动线程数)-1
addWorker方法实现主流程的简单版如下图:
复杂版:
6 线程池的 Worker 线程模型
线程启动后执行 runWorker() 方法,runWorker() 方法中调用 getTask() 方法从阻塞队列中获取任务,获取到任务后先执行 beforeExecute() 钩子函数,再执行任务,然后再执行 afterExecute() 钩子函数。若超时获取不到任务会调用 processWorkerExit() 方法执行 Worker 线程的清理工作。
6.1Worker对象的运作流程
Worker继承自AQS,具有锁的功能,实现了Runable接口,具有线程的功能。
-
- 首先Worker对象继承了AQS(AbstractQueuedSynchronizer),具有锁的功能,并实现了Runnable接口,具有线程的功能。
它在初始化时会传入firstTask,并且赋值给Worker内部维护的一个firstTask,它会禁止线程被中断,使用了AQS的setState(-1)方法,并使用线程工厂方法实例化该线程对象
-
- Worker对象的run方法主要在thread被start()之后,执行runWorker的方法
-
- runWoker方法:
①解锁,允许线程被打断
②会对firstTask进行一个消费
③或者使用getTask获取任务然后执行
④执行完对该worker加锁,进行worker回收处理(完成线程数+1,移除worker)
runWorker()的逻辑如下:
从阻塞队列获取任务:
6.2 为什么Worker被设计为不可重入?
这就需要知道那些操作可能会发生中断工作线程的操作。目前主要有以下几个:
- setCorePoolSize();
- setMaximumPoolSize();
- setKeppAliveTime();
- allowCoreThreadTimeOut();
- shutdown();
- tryTerminate();
如果锁可以重入,调用诸如setCorePoolSize等线程池控制方法时可以再次获取锁,那么可能会导致调用线程池控制方法期间中断正在运行的工作线程。jdk不希望在调用像setCorePoolSize这样的池控制方法时重新获取锁。
7 线程池为什么使用阻塞队列?
线程池创建线程需要获取mainlock这个全局锁,影响并发效率,阻塞队列可以很好的缓冲。
如果新任务的到达速率超过了线程池的处理速率,那么新到来的请求将累加起来,这样的话将耗尽资源。
8 Executors类创建四种常见线程池
要配置一个线程池是比较复杂的,尤其是对于线程池的原理不是很清楚的情况下,因此在工具类 Executors 面提供了一些静态工厂方法,生成一些常用的线程池,这些线程池都是通过ThreadPoolExecutor来创建的。如下所示:
newFixedThreadPool: 创建固定大小的线程池。每次提交一个任务就创建一个线程,直到线程达到线程池的最大大小。线程池的大小一旦达到最大值就会保持不变,如果某个线程因为执行异常而结束,那么线程池会补充一个新线程。如果希望在服务器上使用线程池,建议使用 newFixedThreadPool方法来创建线程池,这样能获得更好的性能。
newSingleThreadExecutor: 创建一个单线程的线程池。这个线程池只有一个线程在工作,也就是相当于单线程串行执行所有任务。如果这个唯一的线程因为异常结束,那么会有一个新的线程来替代它。此线程池保证所有任务的执行顺序按照任务的提交顺序执行。
newCachedThreadPool: 创建一个可缓存的线程池。如果线程池的大小超过了处理任务所需要的线程,那么就会回收部分空闲(60 秒不执行任务)的线程,当任务数增加时,此线程池又可以智能的添加新线程来处理任务。此线程池不会对线程池大小做限制,线程池大小完全依赖于操作系统(或者说 JVM)能够创建的最大线程大小。
newScheduledThreadPool: 创建一个大小无限的线程池。此线程池支持定时以及周期性执行任务的需求。
8.1 newFixedThreadPool
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>()); }
8.2 newSingleThreadExeecutor
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>())); }
8.3 newCachedThreadPool
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>()); }
8.4 newScheduledThreadPool
public static ScheduledExecutorService newScheduledThreadPool(
int corePoolSize, ThreadFactory threadFactory) {
return new ScheduledThreadPoolExecutor(corePoolSize, threadFactory);
}
9 在 Java 中 Executor 和 Executors 的区别?
Java 5+中的 Executor 接口定义一个执行线程的工具。它的子类型即线程池接口是 ExecutorService。
Executors 工具类的不同方法按照我们的需求创建了不同的线程池,来满足业务的需求。
Executor 接口对象能执行我们的线程任务。
ExecutorService 接口继承了 Executor 接口并进行了扩展,提供了更多的方法我们能获得任务执行的状态并且可以获取任务的返回值。
使用 ThreadPoolExecutor 可以创建自定义线程池。
Future 表示异步计算的结果,他提供了检查计算是否完成的方法,以等待计算的完成,并可以使用 get()方法获取计算的结果。
10 向线程池提交任务的两种方式
向线程池提交任务的两种方式大致如下:
方式一:调用execute()方法,例如:
//Executor 接口中的方法
void execute(Runnable command);
方式二:调用submit()方法,例如:
//ExecutorService 接口中的方法
<T> Future<T> submit(Callable<T> task);
<T> Future<T> submit(Runnable task, T result);
Future<?> submit(Runnable task);
10 线程池中 submit() 和 execute() 方法有什么区别?
接收参数: execute()只能执行 Runnable 类型的任务。submit()可以执行 Runnable 和 Callable 类型的任务。
返回值: submit()方法可以返回持有计算结果的 Future 对象,而execute()没有
异常处理: submit()方便Exception处理
11 Executors和ThreaPoolExecutor创建线程池的区别
《阿里巴巴Java开发手册》中强制线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。
Executors 各个方法的弊端:
newFixedThreadPool 和 newSingleThreadExecutor:
主要问题是堆积的请求处理队列可能会耗费非常大的内存,甚至 OOM。newCachedThreadPool 和 newScheduledThreadPool:
主要问题是线程数最大数是 Integer.MAX_VALUE,可能会创建数量非常多的线程,甚至 OOM。
ThreaPoolExecutor创建线程池方式只有一种,就是走它的构造函数,参数自己指定
12 ScheduledThreadPoolExecutor详解
使用Timer和TimerTask存在一些缺陷:
- Timer只创建了一个线程。当你的任务执行的时间超过设置的延时时间将会产生一些问题。
- Timer创建的线程没有处理异常,因此一旦抛出非受检异常,该线程会立即终止。
JDK 5.0以后推荐使用ScheduledThreadPoolExecutor。该类属于Executor Framework,它除了能处理异常外,还可以创建多个线程解决上面的问题
ScheduledThreadPoolExecutor继承自ThreadPoolExecutor。它主要用来在给定的延迟之后执行任务,或者定期执行任务。
ScheduledThreadPoolExecutor的功能与Timer类似,但比Timer更强大,更灵活,Timer对应的是单个后台线程,而ScheduledThreadPoolExecutor可以在构造函数中指定多个对应的后台线程数。
12.1 创建ScheduledThreadPoolExecutor
ScheduledThreadPoolExecutor通常使用工厂类Executors来创建,Executors可以创建两种类型的ScheduledThreadPoolExecutor,如下:
- ScheduledThreadPoolExecutor:可以执行并行任务也就是多条线程同时执行。
- SingleThreadScheduledExecutor:可以执行单条线程。
12.2 ScheduledThreadPoolExecutor和SingleThreadScheduledExecutor的适用场景
- ScheduledThreadPoolExecutor:适用于多个后台线程执行周期性任务,同时为了满足资源管理的需求而需要限制后台线程数量的应用场景。
- SingleThreadScheduledExecutor:适用于需要单个后台线程执行周期任务,同时需要保证任务顺序执行的应用场景。
四、并发容器
1 什么是ConcurrentHashMap?
ConcurrentHashMap是Java中的一个线程安全且高效的HashMap实现。平时涉及高并发如果要用map结构,那第一时间想到的就是它。相对于hashmap来说,ConcurrentHashMap就是线程安全的map,其中利用了锁分段的思想提高了并发度。
ConcurrentHashMap在JDK1.6的版本网上资料很多,有兴趣的可以去看看。 JDK 1.6版本关键要素:
- segment继承了ReentrantLock充当锁的角色,为每一个segment提供了线程安全的保障;
- segment维护了哈希散列表的若干个桶,每个桶由HashEntry构成的链表。
而到了JDK 1.8的ConcurrentHashMap就有了很大的变化,光是代码量就足足增加了很多。
1.8版本舍弃了segment,并且大量使用了synchronized,以及CAS无锁操作以保证ConcurrentHashMap操作的线程安全性。
为什么不用ReentrantLock而用synchronized ?
减少内存开销: 如果使用ReentrantLock则需要节点继承AQS来获得同步支持,增加内存开销,而1.8中只有头节点需要进行同步。
内部优化: synchronized则是JVM直接支持的,JVM能够在运行时作出相应的优化措施:锁粗化、锁消除、锁自旋等等。synchronzied做了很多的优化,包括偏向锁,轻量级锁,重量级锁,可以依次向上升级锁状态,但不能降级,因此,使用synchronized相较于ReentrantLock的性能会持平甚至在某些情况更优。
但是有些人说会降级!!!
多线程中锁的降级:有的观点认为Java不会进行锁降级。实际锁降级确实是会发生的。
锁会从轻量级锁降为无锁状态
具体的触发时机:在全局安全点(safepoint)中,执行清理任务的时候会触发尝试降级锁。当锁降级时,主要进行了以下操作:
- 恢复锁对象的markword对象头;
- 重置ObjectMonitor,然后将该ObjectMonitor放入全局空闲列表,等待后续使用。
1.1 那么它到底是如何实现线程安全的?
JDK1.8 后采用了 CAS + synchronized 来保证并发安全性。数据结构与HashMap一致,都是数组+链表+红黑树。
2 Java 中的同步集合与并发集合有什么区别?
同步集合与并发集合都为多线程和并发提供了合适的线程安全的集合,不过并发集合的可扩展性更高。
在 Java1.5 之前程序员们只有同步集合来用且在多线程并发的时候会导致争用,阻碍了系统的扩展性。Java5 介绍了并发集合像ConcurrentHashMap,不仅提供线程安全还用锁分离和内部分区等现代技术提高了可扩展性。
3 SynchronizedMap 和 ConcurrentHashMap 有什么区别?
SynchronizedMap 一次锁住整张表来保证线程安全,所以每次只能有一个线程来访为 map。
ConcurrentHashMap 通过CAS + synchronized 来保证并发安全性。
4 CopyOnWriteArrayList 是什么,可以用于什么应用场景?有哪些优缺点?
CopyOnWriteArrayList 是一个并发容器。有很多人称它是线程安全的,我认为这句话不严谨,缺少一个前提条件,那就是非复合场景下操作它是线程安全的。
CopyOnWriteArrayList(免锁容器)的好处之一是当多个迭代器同时遍历和修改这个列表时,不会抛出 ConcurrentModificationException。在CopyOnWriteArrayList 中,写入将导致创建整个底层数组的副本,而源数组将保留在原地,使得复制的数组在被修改时,读取操作可以安全地执行。
4.1 CopyOnWriteArrayList 的使用场景
通过源码分析,我们看出它的优缺点比较明显,所以使用场景也就比较明显。就是合适读多写少的场景。
4.2 CopyOnWriteArrayList 的缺点
- 由于写操作的时候,需要拷贝数组,会消耗内存,如果原数组的内容比较多的情况下,可能导致 young gc 或者 full gc。
- 不能用于实时读的场景,像拷贝数组、新增元素都需要时间,所以调用一个 set 操作后,读取到数据可能还是旧的,虽然CopyOnWriteArrayList 能做到最终一致性,但是还是没法满足实时性要求。
- 由于实际使用中可能没法保证 CopyOnWriteArrayList 到底要放置多少数据,万一数据稍微有点多,每次 add/set 都要重新复制数组,这个代价实在太高昂了。在高性能的互联网应用中,这种操作分分钟引起故障。
4.3 CopyOnWriteArrayList 的设计思想
- 读写分离,读和写分开
- 最终一致性
- 使用另外开辟空间的思路,来解决并发冲突
5 ThreadLocal 是什么?有哪些使用场景?
ThreadLocal 是一个本地线程副本变量工具类,在每个线程中都创建了一个 ThreadLocalMap 对象,简单说 ThreadLocal 就是一种以空间换时间的做法,每个线程可以访问自己内部 ThreadLocalMap 对象内的 value。通过这种方式,避免资源在多线程间共享。
一个ThreadLocal在一个线程中是共享的,在不同线程之间又是隔离的(每个线程都只能看到自己线程的值)
原理: 线程局部变量是局限于线程内部的变量,属于线程自身所有,不在多个线程间共享。Java提供ThreadLocal类来支持线程局部变量,是一种实现线程安全的方式。但是在管理环境下(如 web 服务器)使用线程局部变量的时候要特别小心,在这种情况下,工作线程的生命周期比任何应用变量的生命周期都要长。任何线程局部变量一旦在工作完成后没有释放,Java 应用就存在内存泄露的风险。
经典的使用场景: 是为每个线程分配一个 JDBC 连接 Connection。这样就可以保证每个线程的都在各自的 Connection 上进行数据库的操作,不会出现 A 线程关了 B线程正在使用的 Connection; 还有 Session 管理 等问题。
ThreadLocal<Connection> connection = new ThreadLocal<Connection>(){
public Connection initialValue(){
return DriverManager.getConnection(...);
}
};
6 什么是ThreadLocal变量
ThreadLoal 变量,线程局部变量,同一个 ThreadLocal 所包含的对象,在不同的 Thread 中有不同的副本。这里有几点需要注意:
- 因为每个 Thread 内有自己的实例副本,且该副本只能由当前 Thread 使用。这是也是 ThreadLocal 命名的由来。
- 既然每个 Thread 有自己的实例副本,且其它 Thread 不可访问,那就不存在多线程间共享的问题。
ThreadLocal 提供了线程本地的实例。它与普通变量的区别在于,每个使用该变量的线程都会初始化一个完全独立的实例副本。ThreadLocal 变量通常被private static修饰。当一个线程结束时,它所使用的所有 ThreadLocal 相对的实例副本都可被回收。
总的来说,ThreadLocal 适用于每个线程需要自己独立的实例且该实例需要在多个方法中被使用,也即变量在线程间隔离而在方法或类间共享的场景。
7 ThreadLocal实现原理
首先 ThreadLocal 是一个泛型类,保证可以接受任何类型的对象。
因为一个线程内可以存在多个 ThreadLocal 对象,所以其实是 ThreadLocal 内部维护了一个 Map ,这个 Map 不是直接使用的 HashMap ,而是 ThreadLocal 实现的一个叫做 ThreadLocalMap 的静态内部类,这个类自己实现了Map功能。而我们使用的 get()、set() 方法其实都是调用了这个ThreadLocalMap类对应的 get()、set() 方法。
7.1 API介绍
7.2 ThreadLocal使用例子
public class ThreadLocalTest {
private static ThreadLocal<Integer> num = new ThreadLocal<Integer>() {
// 重写这个方法,可以修改“线程变量”的初始值,默认是null
@Override
protected Integer initialValue() {
return 0;
}
};
public static void main(String[] args) {
// 创建一号线程
new Thread(new Runnable() {
@Override
public void run() {
// 在一号线程中将ThreadLocal变量设置为1
num.set(1);
System.out.println("一号线程中ThreadLocal变量中保存的值为:" + num.get());
}
}).start();
// 创建二号线程
new Thread(new Runnable() {
@Override
public void run() {
num.set(2);
System.out.println("二号线程中ThreadLocal变量中保存的值为:" + num.get());
}
}).start();
//为了让一二号线程执行完毕,让主线程睡500ms
try {
Thread.sleep(500);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
System.out.println("主线程中ThreadLocal变量中保存的值:" + num.get());
}
}
结果:
一号线程中ThreadLocal变量中保存的值为:1
二号线程中ThreadLocal变量中保存的值为:2
主线程中ThreadLocal变量中保存的值:0
7.3 原理分析
每个Thread对象都有一个ThreadLocalMap,当创建一个ThreadLocal的时候,就会将该ThreadLocal对象添加到该Map中,其中键就是ThreadLocal,值可以是任意类型。
每一个Thread都会有一个ThreadLocalMap对象
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
Thread类中有一个ThreadLocalMap成员变量。
8 ThreadLocal内存泄漏
ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。所以,如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。这样一来,ThreadLocalMap 中就会出现key为null的Entry。假如我们不做任何措施的话,value 永远无法被GC 回收,这个时候就可能会产生内存泄露。
ThreadLocalMap实现中已经考虑了这种情况,在调用 set()、get()、remove() 方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal方法后 最好手动调用remove()方法
9 什么是阻塞队列?阻塞队列的实现原理是什么?如何使用阻塞队列来实现生产者-消费者模型?
阻塞队列(BlockingQueue)是一个支持两个附加操作的队列。
这两个附加的操作是:
- 在队列为空时,获取元素的线程会等待队列变为非空。
- 当队列满时,存储元素的线程会等待队列可用
9.1 阻塞队列原理:
其实阻塞队列实现阻塞同步的方式很简单,使用的就是是lock锁的多条件(condition)阻塞控制。使用BlockingQueue封装了根据条件阻塞线程的过程,而我们就不用关心繁琐的await/signal操作了。
JDK7 提供了 7 个阻塞队列。
- ArrayBlockingQueue :一个由数组结构组成的有界阻塞队列。
- LinkedBlockingQueue :一个由链表结构组成的有界阻塞队列。
- PriorityBlockingQueue :一个支持优先级排序的无界阻塞队列。
- DelayQueue:一个使用优先级队列实现的无界阻塞队列。
- SynchronousQueue:一个不存储元素的阻塞队列。
- LinkedTransferQueue:一个由链表结构组成的无界阻塞队列。
- LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列。
9 Java阻塞队列BlockingQueue里add、offer、put,take、poll的区别
生产
add、offer、put这3个方法都是往队列尾部添加元素,区别如下:
- add:不会阻塞,添加成功时返回true,不响应中断,当队列已满导致添加失败时抛出IllegalStateException。
- offer:不会阻塞,添加成功时返回true,因队列已满导致添加失败时返回false,不响应中断。
- put:会阻塞会响应中断。
消费
- take、poll方法能获取队列头部第1个元素,区别如下:
- take:会响应中断,会一直阻塞直到取得元素或当前线程中断。
- poll:会响应中断,会阻塞,阻塞时间参照方法里参数timeout.timeUnit,当阻塞时间到了还没取得元素会返回null
10 并发容器之ArrayBlockingQueue
ArrayBlockingQueue内部是采用数组进行数据存储的(属性items),为了保证线程安全,采用的是ReentrantLock lock,为了保证可阻塞式的插入删除数据利用的是Condition,当获取数据的消费者线程被阻塞时会将该线程放置到notEmpty等待队列中,当插入数据的生产者线程被阻塞时,会将该线程放置到notFull等待队列中。
而notEmpty和notFull等中要属性在构造方法中进行创建:
public ArrayBlockingQueue(int capacity, boolean fair) {
if (capacity <= 0)
throw new IllegalArgumentException();
this.items = new Object[capacity];
lock = new ReentrantLock(fair);
notEmpty = lock.newCondition();
notFull = lock.newCondition();
}
接下来,主要看看可阻塞式的put和take方法是怎样实现的。
10.1 put
put(E e)方法源码如下:
public void put(E e) throws InterruptedException {
checkNotNull(e);
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
//如果当前队列已满,将线程移入到notFull等待队列中
while (count == items.length)
notFull.await();
//满足插入数据的要求,直接进行入队操作
enqueue(e);
} finally {
lock.unlock();
}
}
该方法的逻辑很简单,当队列已满时(count == items.length)将线程移入到notFull等待队列中,如果当前满足插入数据的条件,就可以直接调用enqueue(e)插入数据元素。enqueue方法源码为:
private void enqueue(E x) {
// assert lock.getHoldCount() == 1;
// assert items[putIndex] == null;
final Object[] items = this.items;
//插入数据
items[putIndex] = x;
if (++putIndex == items.length)
putIndex = 0;
count++;
//通知消费者线程,当前队列中有数据可供消费
notEmpty.signal();
}
enqueue方法的逻辑同样也很简单,先完成插入数据,即往数组中添加数据(items[putIndex] = x),然后通知被阻塞的消费者线程,当前队列中有数据可供消费(notEmpty.signal())。
10.2 take
take方法源码如下:
public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
//如果队列为空,没有数据,将消费者线程移入等待队列中
while (count == 0)
notEmpty.await();
//获取数据
return dequeue();
} finally {
lock.unlock();
}
}
take方法也主要做了两步:
- 如果当前队列为空的话,则将获取数据的消费者线程移入到等待队列中;
- 若队列不为空则获取数据,即完成出队操作dequeue。
dequeue方法源码为:
private E dequeue() {
// assert lock.getHoldCount() == 1;
// assert items[takeIndex] != null;
final Object[] items = this.items;
@SuppressWarnings("unchecked")
//获取数据
E x = (E) items[takeIndex];
items[takeIndex] = null;
if (++takeIndex == items.length)
takeIndex = 0;
count--;
if (itrs != null)
itrs.elementDequeued();
//通知被阻塞的生产者线程
notFull.signal();
return x;
}
dequeue方法也主要做了两件事情:
- 获取队列中的数据,即获取数组中的数据元素((E) items[takeIndex]);
- 通知notFull等待队列中的线程,使其由等待队列移入到同步队列中,使其能够有机会获得lock,并执行完成功退出。
从以上分析,可以看出put和take方法主要是通过condition的通知机制来完成可阻塞式的插入数据和获取数据。在理解ArrayBlockingQueue后再去理解LinkedBlockingQueue就很容易了。
11 为什么ArrayBlockingQueue put和take不能并行?
A:因为put 和 take用同一个reetrantlock实现。
11 并发容器之LinkedBlockingQueue详解
LinkedBlockingQueue是用链表实现的有界阻塞队列,当构造对象时未指定队列大小时,队列默认大小为Integer.MAX_VALUE。这样的话,如果生产者的速度一旦大于消费者的速度,也许还没有等到队列满阻塞产生,系统内存就有可能已被消耗殆尽了。所以强烈建议指定队列大小,指定了大小,当超过队列大小时,会阻塞。
只有当队列缓冲区达到最大值缓存容量时(LinkedBlockingQueue可以通过构造函数指定该值),才会阻塞生产者队列,直到消费者从队列中消费掉一份数据,生产者线程会被唤醒,反之对于消费者这端的处理也基于同样的原理。
而LinkedBlockingQueue之所以能够高效的处理并发数据,还因为其对于生产者端和消费者端分别采用了独立的锁来控制数据同步,这也意味着在高并发的情况下生产者和消费者可以并行地操作队列中的数据,以此来提高整个队列的并发性能。
LinkedBlockingQueue使用了takeLock和putLock两把锁,分别用于阻塞队列的读写线程,也就是说,读线程和写线程可以同时运行,在多线程高并发场景,应该可以有更高的吞吐量,性能比单锁更高。
LinkedBlockingQueue的主要属性有:
/** Current number of elements */
private final AtomicInteger count = new AtomicInteger();
/**
* Head of linked list.
* Invariant: head.item == null
*/
transient Node<E> head;
/**
* Tail of linked list.
* Invariant: last.next == null
*/
private transient Node<E> last;
/** Lock held by take, poll, etc */
private final ReentrantLock takeLock = new ReentrantLock();
/** Wait queue for waiting takes */
private final Condition notEmpty = takeLock.newCondition();
/** Lock held by put, offer, etc */
private final ReentrantLock putLock = new ReentrantLock();
/** Wait queue for waiting puts */
private final Condition notFull = putLock.newCondition();
可以看出与ArrayBlockingQueue主要的区别是,LinkedBlockingQueue在插入数据和删除数据时分别是由两个不同的lock(takeLock和putLock)来控制线程安全的,因此,也由这两个lock生成了两个对应的condition(notEmpty和notFull)来实现可阻塞的插入和删除数据。并且,采用了链表的数据结构来实现队列
11.1 put方法详解
- 队列已满,阻塞等待。
- 队列未满,创建一个node放入队列,如果放完还有空间,则唤醒其它线程继续添加。如果放入队列之前没有元素,放完以后要唤醒其它线程进行消费。
offer方法与put方法区别:当队列没有可用元素时:put方法是阻塞等待,offer方法直接返回false。
public void put(E e) throws InterruptedException {
if (e == null) throw new NullPointerException();
// Note: convention in all put/take/etc is to preset local var
// holding count negative to indicate failure unless set.
int c = -1;
Node<E> node = new Node<E>(e);
final ReentrantLock putLock = this.putLock;
final AtomicInteger count = this.count;
putLock.lockInterruptibly();
try {
/*
* Note that count is used in wait guard even though it is
* not protected by lock. This works because count can
* only decrease at this point (all other puts are shut
* out by lock), and we (or some other waiting put) are
* signalled if it ever changes from capacity. Similarly
* for all other uses of count in other wait guards.
*/
//如果队列已满,则阻塞当前线程,将其移入等待队列
while (count.get() == capacity) {
notFull.await();
}
//入队操作,插入数据
enqueue(node);
c = count.getAndIncrement();
//若队列满足插入数据的条件,则通知被阻塞的生产者线程
if (c + 1 < capacity)
notFull.signal();
} finally {
putLock.unlock();
}
if (c == 0)
signalNotEmpty();
}
put方法的逻辑也同样很容易理解,可见注释。基本上和ArrayBlockingQueue的put方法一样。
11.2 take方法详解
- 队列为空,阻塞等待。
- 队列不为空,从队首获取并移除一个元素,如果消费后还有元素在队列中,继续唤醒下一个消费线程进行元素移除。如果放之前队列是满元素,移除完之后要唤醒生产线程进行添加元素。
public E take() throws InterruptedException {
E x;
int c = -1;
final AtomicInteger count = this.count;
final ReentrantLock takeLock = this.takeLock;
takeLock.lockInterruptibly();
try {
//当前队列为空,则阻塞当前线程,将其移入到等待队列中,直至满足条件
while (count.get() == 0) {
notEmpty.await();
}
//移除队头元素,获取数据
x = dequeue();
c = count.getAndDecrement();
//如果当前满足移除元素的条件,则通知被阻塞的消费者线程
if (c > 1)
notEmpty.signal();
} finally {
takeLock.unlock();
}
if (c == capacity)
signalNotFull();
return x;
}
take方法的主要逻辑请见于注释,也很容易理解。
12 ArrayBlockingQueue与LinkedBlockingQueue的比较
相同点: ArrayBlockingQueue和LinkedBlockingQueue都是通过condition通知机制来实现可阻塞式插入和删除元素,并满足线程安全的特性;
不同点:
- ArrayBlockingQueue底层是采用的数组进行实现,而LinkedBlockingQueue则是采用单向链表数据结构;
- ArrayBlockingQueue在插入或删除元素时不会产生或销毁任何额外的对象实例,LinkedBlockingQueue则会生成一个额外的Node对象。
- ArrayBlockingQueue插入和删除数据,只采用了一个lock,而LinkedBlockingQueue则是在插入和删除分别采用了putLock和takeLock,这样可以降低线程由于线程无法获取到lock而进入WAITING状态的可能性,从而提高了线程并发执行的效率。
- ArrayBlockingQueue不能同时存取,因为只用了一个lock,LinkedBlockingQueue可以同时存取。
13 既然LinkedBlockingQueue兄弟用双锁实现,而且性能更好,为什么ArrayBlockingQueue不使用双锁实现呢?
ArrayBlockingQueue的实现是“单锁+两个condition”,而LinkedBlockingQueue是采用的“双锁+各自的condition”来实现的。两个类的作者都是Doug Lea,这是为什么呢?
可能是:
LinkedBlockingQueue的较大一部分时间需要构造节点,导致较长的等待。所以同时存取有较大优化。
而ArrayBlockingQueue的不用构造节点,加锁和解锁的时间可能占比较大。
转成双锁之后,对比原来的存取操作,需要多竞争两次。一次是Atomic变量的cas操作,另一次是获得另一把锁的通知操作。可能这部分的损耗,已经比并发存取带来收益更大。
也许是因为ArrayBlockingQueue的数据写入和获取操作已经足够轻巧,以至于引入独立的锁机制,除了给代码带来额外的复杂性外,其在性能上完全占不到任何便宜。
五、原子类
1 什么是原子操作?在 Java Concurrency API 中有哪些原子类(atomic classes)?
原子操作(atomic operation)意为”不可被中断的一个或一系列操作” 。是指一个不受其他操作影响的操作任务单元。原子操作是在多线程环境下避免数据不一致必须的手段。
处理器使用基于对缓存加锁或总线加锁的方式来实现多处理器之间的原子操作。
在 Java 中可以通过锁和循环 CAS 的方式来实现原子操作。
CAS 操作——Compare & Set,或是 Compare & Swap,现在几乎所有的 CPU 指令都支持 CAS 的原子操作。在 Java 中可以通过锁和循环 CAS 的方式来实现原子操作。
1.1 为什么需要原子类
int++并不是一个原子操作,所以当一个线程读取它的值并加 1 时,另外一个线程有可能会读到之前的值,这就会引发错误。
为了解决这个问题,必须保证增加操作是原子的,在 JDK1.5 之前我们可以使用同步技术来做到这一点。到 JDK1.5,java.util.concurrent.atomic 包提供了 int 和long 类型的原子包装类,它们可以自动的保证对于他们的操作是原子的并且不需要使用同步。
1.2 JUC中有哪些原子类
java.util.concurrent 这个包里面提供了一组原子类。其基本的特性就是在多线程环境下,当有多个线程同时执行这些类的实例包含的方法时,具有排他性,即当某个线程进入方法,执行其中的指令时,不会被其他线程打断,而别的线程就像自旋锁一样,一直等到该方法执行完成,才由 JVM 从等待队列中选择另一个线程进入,这只是一种逻辑上的理解。
原子类: AtomicBoolean,AtomicInteger,AtomicLong,AtomicReference
原子数组: AtomicIntegerArray,AtomicLongArray,AtomicReferenceArray
原子属性更新器: AtomicLongFieldUpdater,AtomicIntegerFieldUpdater,AtomicReferenceFieldUpdater
解决 ABA 问题的原子类:AtomicMarkableReference(通过引入一个 boolean来反映中间有没有变过),AtomicStampedReference(通过引入一个 int 来累加来反映中间有没有变过)
2 说一下 atomic 的原理?
Atomic包中的类基本的特性就是在多线程环境下,当有多个线程同时对单个(包括基本类型及引用类型)变量进行操作时,具有排他性,即当多个线程同时对该变量的值进行更新时,仅有一个线程能成功,而未成功的线程可以向自旋锁一样,继续尝试,一直等到执行成功。
AtomicInteger 类主要利用 CAS (compare and swap) + volatile 和 native 方法来保证原子操作,从而避免 synchronized 的高开销,执行效率大为提升。
CAS的原理是拿期望的值和原本的一个值作比较,如果相同则更新成新的值。UnSafe 类的 objectFieldOffset() 方法是一个本地方法,这个方法是用来拿到“原来的值”的内存地址,返回值是 valueOffset。另外 value 是一个volatile变量,在内存中可见,因此 JVM 可以保证任何时刻任何线程总能拿到该变量的最新值。
AtomicInteger.java源码如下:
public class AtomicInteger extends Number implements java.io.Serializable {
private static final long serialVersionUID = 6214790243416807050L;
// setup to use Unsafe.compareAndSwapInt for updates
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;
static {
try {
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
private volatile int value;
public AtomicInteger(int initialValue) {
value = initialValue;
}
public AtomicInteger() {
}
public final int get() {
return value;
}
public final void set(int newValue) {
value = newValue;
}
public final void lazySet(int newValue) {
unsafe.putOrderedInt(this, valueOffset, newValue);
}
public final int getAndSet(int newValue) {
return unsafe.getAndSetInt(this, valueOffset, newValue);
}
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
public final boolean weakCompareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
/**
* Atomically decrements by one the current value.
*
* @return the previous value
*/
public final int getAndDecrement() {
return unsafe.getAndAddInt(this, valueOffset, -1);
}
public final int getAndAdd(int delta) {
return unsafe.getAndAddInt(this, valueOffset, delta);
}
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
public final int decrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, -1) - 1;
}
public final int addAndGet(int delta) {
return unsafe.getAndAddInt(this, valueOffset, delta) + delta;
}
public final int getAndUpdate(IntUnaryOperator updateFunction) {
int prev, next;
do {
prev = get();
next = updateFunction.applyAsInt(prev);
} while (!compareAndSet(prev, next));
return prev;
}
public final int updateAndGet(IntUnaryOperator updateFunction) {
int prev, next;
do {
prev = get();
next = updateFunction.applyAsInt(prev);
} while (!compareAndSet(prev, next));
return next;
}
public final int getAndAccumulate(int x,
IntBinaryOperator accumulatorFunction) {
int prev, next;
do {
prev = get();
next = accumulatorFunction.applyAsInt(prev, x);
} while (!compareAndSet(prev, next));
return prev;
}
public final int accumulateAndGet(int x,
IntBinaryOperator accumulatorFunction) {
int prev, next;
do {
prev = get();
next = accumulatorFunction.applyAsInt(prev, x);
} while (!compareAndSet(prev, next));
return next;
}
}
六 并发工具
1 countDownLatch
countDownLatch类中只提供了一个构造器:
//参数count为计数值
public CountDownLatch(int count) { };
类中有三个方法是最重要的:
//调用await()方法的线程会被挂起,它会等待直到count值为0才继续执行
public void await() throws InterruptedException { };
//和await()类似,只不过等待一定的时间后count值还没变为0的话就会继续执行
public boolean await(long timeout, TimeUnit unit) throws InterruptedException { };
//将count值减1
public void countDown() { };
使用示例:
/**
* 主线程等待子线程执行完成再执行
*/
public class CountdownLatchTest {
public static void main(String[] args) {
ExecutorService service = Executors.newFixedThreadPool(3);
final CountDownLatch latch = new CountDownLatch(3);
for (int i = 0; i < 3; i++) {
Runnable runnable = new Runnable() {
@Override
public void run() {
try {
System.out.println("子线程" + Thread.currentThread().getName() + "开始执行");
Thread.sleep((long) (Math.random() * 10000));
System.out.println("子线程"+Thread.currentThread().getName()+"执行完成");
latch.countDown();//当前线程调用此方法,则计数减一
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
service.execute(runnable);
}
try {
System.out.println("主线程"+Thread.currentThread().getName()+"等待子线程执行完成...");
latch.await();//阻塞当前线程,直到计数器的值为0
System.out.println("主线程"+Thread.currentThread().getName()+"开始执行...");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
2 在 Java 中 CycliBarriar 和 CountdownLatch 有什么区别?
CountDownLatch与CyclicBarrier都是用于控制并发的工具类,都可以理解成维护的就是一个计数器,但是这两者还是各有不同侧重点的:
CountDownLatch一般用于某个线程A等待若干个其他线程执行完任务之后,它才执行;而CyclicBarrier一般用于一组线程互相等待至某个状态,然后这一组线程再同时执行;CountDownLatch强调一个线程等多个线程完成某件事情。CyclicBarrier是多个线程互等,等大家都完成,再携手共进。
调用CountDownLatch的countDown方法后,当前线程并不会阻塞,会继续往下执行;而调用CyclicBarrier的await方法,会阻塞当前线程,直到CyclicBarrier指定的线程全部都到达了指定点的时候,才能继续往下执行;
CountDownLatch方法比较少,操作比较简单,而CyclicBarrier提供的方法更多,比如能够通过getNumberWaiting(),isBroken()这些方法获取当前多个线程的状态,并且CyclicBarrier的构造方法可以传入barrierAction,指定当所有线程都到达时执行的业务功能;
CountDownLatch是不能复用的,而CyclicLatch是可以复用的。
3 并发工具之Semaphore与Exchanger
3.1 Semaphore 有什么作用
Semaphore 就是一个信号量,它的作用是限制某段代码块的并发数。
Semaphore有一个构造函数,可以传入一个 int 型整数 n,表示某段代码最多只有 n 个线程可以访问,如果超出了 n,那么请等待,等到某个线程执行完毕这段代码块,下一个线程再进入。
由此可以看出如果 Semaphore 构造函数中传入的 int 型整数 n=1,相当于变成了一个 synchronized 了。
Semaphore(信号量)-允许多个线程同时访问: synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,Semaphore(信号量)可以指定多个线程同时访问某个资源。
3.2 Semaphore使用
Semaphore的主要方法摘要:
- void acquire():从此信号量获取一个许可,在提供一个许可前一直将线程阻塞,否则线程被中断。
- void release():释放一个许可,将其返回给信号量。
- int availablePermits():返回此信号量中当前可用的许可数。
- boolean hasQueuedThreads():查询是否有线程正在等待获取。
使用示例:
public class SemaphoreTest {
public static void main(String[] args) {
ExecutorService executorService = Executors.newCachedThreadPool();
//信号量,只允许 3个线程同时访问
Semaphore semaphore = new Semaphore(3);
for (int i=0;i<10;i++){
final long num = i;
executorService.submit(new Runnable() {
@Override
public void run() {
try {
//获取许可
semaphore.acquire();
//执行
System.out.println("Accessing: " + num);
Thread.sleep(new Random().nextInt(5000)); // 模拟随机执行时长
//释放
semaphore.release();
System.out.println("Release..." + num);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
}
executorService.shutdown();
}
}
4 Exchanger是什么?
Exchanger是一个用于线程间协作的工具类,用于两个线程间交换数据。
它提供了一个交换的同步点,在这个同步点两个线程能够交换数据。交换数据是通过exchange方法来实现的,如果一个线程先执行exchange方法,那么它会同步等待另一个线程也执行exchange方法,这个时候两个线程就都达到了同步点,两个线程就可以交换数据。
4.1 主要方法
Exchanger():无参构造方法。
V exchange(V v):等待另一个线程到达此交换点(除非当前线程被中断),然后将给定的对象传送给该线程,并接收该线程的对象。
4.2 原理
当一个线程到达 exchange 调用点时,如果其他线程此前已经调用了此方法,则其他线程会被调度唤醒并与之进行对象交换,然后各自返回;如果其他线程还没到达交换点,则当前线程会被挂起,直至其他线程到达才会完成交换并正常返回,或者当前线程被中断或超时返回。
4.3 使用示例
public class Test {
static class Producer extends Thread {
private Exchanger<Integer> exchanger;
private static int data = 0;
Producer(String name, Exchanger<Integer> exchanger) {
super("Producer-" + name);
this.exchanger = exchanger;
}
@Override
public void run() {
for (int i=1; i<5; i++) {
try {
TimeUnit.SECONDS.sleep(1);
data = i;
System.out.println(getName()+" 交换前:" + data);
data = exchanger.exchange(data);
System.out.println(getName()+" 交换后:" + data);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
static class Consumer extends Thread {
private Exchanger<Integer> exchanger;
private static int data = 0;
Consumer(String name, Exchanger<Integer> exchanger) {
super("Consumer-" + name);
this.exchanger = exchanger;
}
@Override
public void run() {
while (true) {
data = 0;
System.out.println(getName()+" 交换前:" + data);
try {
TimeUnit.SECONDS.sleep(1);
data = exchanger.exchange(data);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(getName()+" 交换后:" + data);
}
}
}
public static void main(String[] args) throws InterruptedException {
Exchanger<Integer> exchanger = new Exchanger<Integer>();
new Producer("", exchanger).start();
new Consumer("", exchanger).start();
TimeUnit.SECONDS.sleep(7);
System.exit(-1);
}
}
5 常用的并发工具类有哪些?
- Semaphore(信号量)-允许多个线程同时访问: synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,Semaphore(信号量)可以指定多个线程同时访问某个资源。
- CountDownLatch(倒计时器): CountDownLatch是一个同步工具类,用来协调多个线程之间的同步。这个工具通常用来控制线程等待,它可以让某一个线程等待直到倒计时结束,再开始执行。
- CyclicBarrier(循环栅栏): CyclicBarrier 和 CountDownLatch 非常类似,它也可以实现线程间的技术等待,但是它的功能比 CountDownLatch 更加复杂和强大。主要应用场景和 CountDownLatch 类似。CyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。CyclicBarrier默认的构造方法是 CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用await()方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。
七、并发实践
1 使用本地变量
应该总是使用本地变量,而不是创建一个类或实例变量,通常情况下,开发人员使用对象实例作为变量可以节省内存并可以重用,因为他们认为每次在方法中创建本地变量会消耗很多内存。
下面代码的execute()方法被多线程调用,为了实现一个新功能,你需要一个临时集合Collection,代码中这个临时集合作为静态类变量使用,然后在execute方法的尾部清除这个集合以便下次重用,编写这段代码的人可能认为这是线程安全的,因为 CopyOnWriteArrayList是线程安全的,但是他没有意识到,这个方法execute()是被多线程调用,那么可能多线程中一个线程看到另外一个线程的临时数据,即使使用Collections.synchronizedList也不能保证execute()方法内的逻辑不变性,这个不变性是:这个集合是临时集合,只用来在每个线程执行内部可见即可,不能暴露给其他线程知晓。
解决办法是使用本地List而不是全局的List。
2 使用不可变类
不可变类比如String Integer等一旦创建,不再改变,不可变类可以降低代码中需要的同步数量。
3 最小化锁的作用域范围
任何在锁中的代码将不能被并发执行,如果你有5%代码在锁中,那么根据Amdahl’s law,你的应用形象就不可能提高超过20倍,因为锁中这些代码只能顺序执行,降低锁的涵括范围,上锁和解锁之间的代码越少越好。
4 使用线程池的Excutor,而不是直接new Thread执行
创建一个线程的代价是昂贵的,如果你要得到一个可伸缩的Java应用,你需要使用线程池,使用线程池管理线程。JDK提供了各种ThreadPool线程池和Executor。
5 宁可使用同步工具而不要使用线程对象的wait notify。
从Java 1.5以后增加了需要同步工具如CycicBariier, CountDownLatch 和 Sempahore,你应当优先使用这些同步工具,而不是去思考如何使用线程的wait和notify,通过BlockingQueue实现生产-消费的设计比使用线程的wait和notify要好得多,也可以使用CountDownLatch实现多个线程的等待。
6 使用BlockingQueue实现生产-消费模式
大部分并发问题都可以使用producer-consumer生产-消费设计实现,而BlockingQueue是最好的实现方式,堵塞的队列不只是可以处理单个生产单个消费,也可以处理多个生产和消费。
7 使用并发集合Collection而不是加了同步锁的集合
Java提供了 ConcurrentHashMap CopyOnWriteArrayList 和 CopyOnWriteArraySet以及BlockingQueue Deque and BlockingDeque五大并发集合,宁可使用这些集合,也不用使用Collections.synchronizedList之类加了同步锁的集合, CopyOnWriteArrayList 适合读多写少的场合,ConcurrentHashMap更是经常使用的并发集合。
8 使用Semaphore创建有界的访问
为了建立可靠的稳定的系统,对于数据库 文件系统和socket等资源必须有界bound,Semaphore是一个可以限制这些资源开销的选择,如果某个资源不可以,使用Semaphore可以最低代价堵塞线程等待
9 宁可使用同步代码块,也不使用加同步的方法
使用synchronized 同步代码块只会锁定一个对象,而不会将当前整个方法锁定;如果更改共同的变量或类的字段,首先选择原子性变量,然后使用volatile。如果你需要互斥锁,可以考虑使用ReentrantLock
10 避免使用静态变量
静态变量在并发执行环境会制造很多问题,如果你必须使用静态变量,让它称为final 常量,如果用来保存集合Collection,那么考虑使用只读集合。
11 宁可使用锁,而不是synchronized 同步关键字
Lock锁接口是非常强大,粒度比较细,对于读写操作有不同的锁,这样能够容易扩展伸缩,而synchronized不会自动释放锁,如果你使用lock()上锁,你可以使用unlock解锁
八、并发问题排查
如何在 Windows 和 Linux 上查找哪个线程cpu利用率最高?
windows上面用任务管理器看,linux下可以用 top 这个工具看。
- 找出cpu耗用厉害的进程pid, 终端执行top命令,然后按下shift+p 查找出cpu利用最厉害的pid号
- 根据上面第一步拿到的pid号,top -H -p pid 。然后按下shift+p,查找出cpu利用率最厉害的线程号,比如top -H -p 1328
- 将获取到的线程号转换成16进制,去百度转换一下就行
- 使用jstack工具将进程信息打印输出,jstack pid号 > /tmp/t.dat,比如jstack 31365 > /tmp/t.dat
- 编辑/tmp/t.dat文件,查找线程号对应的信息
九 补充
1 多核CPU硬件结构
多核CPU是将多个CPU核集成到单个芯片中,每个CPU核都是一个单独的处理器。每个CPU核可以有自己单独的Cache,也可以多个CPU核共享同一Cache。下图便是一个不共享Cache的双核CPU体系结构。
在现代的多核硬件结构中,内存对多个CPU核是共享的,CPU核一般都是对称的,因此多核属于共享存储的对称多处理器(Symmetric Multi-processor,SMP)。
在多核硬件结构中,如果要充分发挥硬件的性能,必须要采用多线程(或多进程)执行,使得每个CPU核在同一时刻都有线程在执行。
和单核上的多线程不同,多核上的多个线程是在物理上并行执行的,是一种真正意义上的并行执行,在同一时刻有多个线程在并行执行。而单核上的多线程是一种多线程交错执行,实际上在同一时刻只有一个线程在执行。
2 多核编程模型
前面谈到过多核属于共享存储的SMP,但实际上SMP系统出现在多核之前,服务器硬件中就广泛采用多个CPU构成的SMP系统,如双CPU、四CPU的服务器很早就出现了。多核CPU系统中的编程和多CPU的SMP系统的编程模型是一致的,都属于共享存储的编程模型,在本书中把它叫做多核编程,实际上并不限于在多核CPU系统中的编程,而是可以应用于共享存储的SMP系统中的编程。
3 cpu数、核心数、进程数、线程数的关系,一个核心对应一个进程?一个进程中的线程却可以使用所有核心运行?多核CPU代表有多个CPU?
- cpu:中央处理器
- CPU个数即CPU芯片个数
CPU的核心数是指物理上,也就是硬件上存在着几个核心。比如,双核就是包括2个相对独立的CPU核心单元组,四核就包含4个相对独立的CPU核心单元组。
线程数是一种逻辑的概念,简单地说,就是模拟出的CPU核心数。比如,可以通过一个CPU核心数模拟出2线程的CPU,也就是说,这个单核心的CPU被模拟成了一个类似双核心CPU的功能。我们从任务管理器的性能标签页中看到的是两个CPU。 比如Inte l赛扬G460是单核心,双线程的CPU,Intel 酷睿i3 3220是双核心 四线程,Intel 酷睿i7 4770K是四核心 八线程 ,Intel 酷睿i5 4570是四核心 四线程等等。 对于一个CPU,线程数总是大于或等于核心数的。一个核心最少对应一个线程,但通过超线程技术,一个核心可以对应两个线程,也就是说它可以同时运行两个线程。
4 多核cpu可以同时执行多条指令吗?
可以,几个核就可以同时执行几条指令
5 两个进程可以同时在一个CPU核心上运行吗?在多核CPU下,同一进程下的多个线程可以并行运行吗?
两个进程可以在同一cpu核心上运行,不过不是同时,而是交替,单核cpu。
同一进程下的多个线程可以并行运行,只要核心数够。