# JUC面试问题
# 并发的三要素
- 原子性: 原子,即一个不可再被分割的颗粒。原子性指的是一个或多个操作要么全部执行成功要么全部执行失败。
- 可见性: 一个线程对共享变量的修改,另一个线程能够立刻看到。(synchronized,volatile)
- 有序性: 程序执行的顺序按照代码的先后顺序执行。(处理器可能会对指令进行重排序)
# 同步、异步、并行、并发
- 同步请求:发送一个请求需要等待返回才能够发送下一个请求有等待过程;
- 异步请求:发送一个请求,不需要等待随时可已发送下一条请求;
- 并发:多个任务在同一个CPU核上,按细分的时间片轮流(交替)执行,从逻辑上来看那些任务是同时执行。
- 并行:单位时间内,多个处理器或多核处理器同时处理多个任务,是真正意义上的“同时进行”。
- 串行:有n个任务,由一个线程按顺序执行。由于任务、方法都在一个线程执行所以不存在线程不安全情况,也就不存在临界区的问题。
# 线程与进程的区别
- 根本区别:进程是操作系统资源分配的基本单位,而线程是处理器任务调度和执行的基本单位
- 资源开销:每个进程都有独立的代码和数据空间(程序上下文),程序之间的切换会有较大的开销;线程可以看做轻量级的进程,同一类线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器(PC),线程之间切换的开销小。
- 包含关系:如果一个进程内有多个线程,则执行过程不是一条线的,而是多条线(线程)共同完成的;线程是进程的一部分,所以线程也被称为轻权进程或者轻量级进程。
- 内存分配:同一进程的线程共享本进程的地址空间和资源,而进程之间的地址空间和资源是相互独立的
- 影响关系:一个进程崩溃后,在保护模式下不会对其他进程产生影响,但是一个线程崩溃整个进程都死掉。所以多进程要比多线程健壮。
- 执行过程:每个独立的进程有程序运行的入口、顺序执行序列和程序出口。但是线程不能独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制,两者均可并发执行
# 多进程和多线程区别
- 多进程:操作系统中同时运行的多个程序,多线程:在同一个进程中同时运行的多个任务。
- 多线程下载软件,可以同时运行多个线程,但是通过程序运行的结果发现,每一次结果都不一致。因为多线程存在一个特性随机性。造成的原因:CPU在瞬间不断切换去处理各个线程而导致的, 可以理解成多个线程在抢CPU资源。多线程并不能提高运行速度,但可以提高运行效率,让CPU的使用率更高。但是如果多线程有安全问题或出现频繁的上下文切换时,运算速度可能反而更低。
# 现场池的创建方式
- 继承
Thread
类创建线程(生产不使用) - 实现
Runnable
接口创建线程(生产不使用) - 使用
Callable
和Future
创建线程(生产不使用) - 使用线程池例如用
Executor
框架(生产中不建议使用,推荐使用ThreadPoolExecutor
手动创建线程池)
# 线程池的作用
在Java中,创建一个线程可以通过继承Thread或者实现Runnable接口来实现,但是,如果每个请求都创建一个新线程, 那么创建和销毁线程花费的时间和消耗的系统资源都相当大,甚至可能要比在处理实际的用户请求的时间和资源要多的多。
- 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
- 提高线程的可管理性。线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一分配、调优和监控。
# 线程池的基本类型
- newFixedThreadPool:创建一个固定线程数的线程池。
- newSingleThreadExecutor:创建只有1个线程的线程池。
- newCachedThreadPool:返回一个可根据实际情况调整线程个数的线程池,不限制最大线程 数量,若用空闲的线程则执行任务,若无任务则不创建线程。并且每一个空闲线程会在60秒 后自动回收。
- newScheduledThreadPool: 创建一个可以指定线程的数量的线程池,但是这个线程池还带有 延迟和周期性执行任务的功能,类似定时器。
# ThreadPoolExecutor的原理
/**
* 用给定的初始参数创建一个新的ThreadPoolExecutor。
*/
public ThreadPoolExecutor(int corePoolSize,//线程池的核心线程数量
int maximumPoolSize,//线程池的最大线程数
long keepAliveTime,//当线程数大于核心线程数时,多余的空闲线程存活的最长时间
TimeUnit unit,//时间单位
BlockingQueue<Runnable> workQueue,//任务队列,用来储存等待执行任务的队列
ThreadFactory threadFactory,//线程工厂,用来创建线程,一般默认即可
RejectedExecutionHandler handler//拒绝策略,当提交的任务过多而不能及时处理时,我们可以定制策略来处理任务
) {
if (corePoolSize < 0 ||
maximumPoolSize <= 0 ||
maximumPoolSize < corePoolSize ||
keepAliveTime < 0)
throw new IllegalArgumentException();
if (workQueue == null || threadFactory == null || handler == null)
throw new NullPointerException();
this.corePoolSize = corePoolSize;
this.maximumPoolSize = maximumPoolSize;
this.workQueue = workQueue;
this.keepAliveTime = unit.toNanos(keepAliveTime);
this.threadFactory = threadFactory;
this.handler = handler;
}
可以通过两个方法动态调整线程池的大小。
- setCorePoolSize():设置最大核心线程数
- setMaximumPoolSize():设置最大工作线程数
线程池核心参数
- corePoolSize:指定了线程池中的线程数量,它的数量决定了添加的任务是开辟新的线程去执行,还是放到workQueue任务队列中去;
- maximumPoolSize:指定了线程池中的最大线程数量,这个参数会根据你使用的workQueue任务队列的类型,决定线程池会开辟的最大线程数量;
- keepAliveTime:当线程池中空闲线程数量超过corePoolSize时,多余的线程会在多长时间内被销毁;
- unit:keepAliveTime的单位
- workQueue:任务队列,被添加到线程池中,但尚未被执行的任务;它一般分为直接提交队列、有界任务队列、无界任务队列、优先任务队列几种;
- threadFactory:线程工厂,用于创建线程,一般用默认即可;
- handler:拒绝策略;当任务太多来不及处理时,如何拒绝任务;
ThreadPoolExecutor的工作流程
执行 for 循环,提交8个任务(恰好等于maximumPoolSize[最大线程数] + capacity[队列大小]);通过 threadPoolExecutor.submit 提交Runnable接口实现的执行任务;
- 提交第1个任务时,由于当前线程池中正在执行的任务为0 ,小于3(corePoolSize指定),所以会创建一个线程用来执行提交的任务1;
- 提交第2,3个任务的时候,由于当前线程池中正在执行的任务数量小于等于3(corePoolSize指定),所以会为每一个提交的任务创建一个线程来执行任务;
- 当提交第4个任务的时候,由于当前正在执行的任务数量为3(因为每个线程任务执行时间为10s,所以提交第4个任务的时候,前面3个线程都还在执行中),此时会将第4个任务存放到workQueue队列中等待执行;
- 由于workQueue队列的大小为2,所以该队列中也就只能保存2个等待执行的任务,所以第5个任务也会保存到任务队列中;
- 当提交第6个任务的时候,因为当前线程池正在执行的任务数量为3,workQueue队列中存储的任务数量也满了,这时会判断当前线程池中正在执行的任务的数量是否小于6(maximumPoolSize指定);
- 如果小于6,那么就会新创建一个线程来执行提交的任务6;
- 执行第7,8个任务的时候,也要判断当前线程池中正在执行的任务数是否小于6(maximumPoolSize指定),如果小于6,那么也会立即新建线程来执行这些提交的任务;
- 此时6个任务都已经提交完毕,那 workQueue 队列中的等待 任务4和任务5什么时候执行呢?
- 当任务1执行完毕后(10s后),执行任务1的线程并没有被销毁掉,而是获取workQueue 中的任务4来执行;
- 当任务2执行完毕后,执行任务2的线程也没有被销毁,而是获取workQueue中的任务5来执行;
通过上面流程的分析,也就知道了之前案例的输出结果的原因。其实,线程池中会线程执行完毕后,并不会被立刻销毁, 线程池中会保留 corePoolSize 数量的线程,当 workQueue 队列中存在任务或者有新提交任务时,那么会通过线程池中已有的线程来执行任务, 避免了频繁的线程创建与销毁,而大于 corePoolSize 小于等于 maximumPoolSize 创建的线程,则会在空闲指定时间(keepAliveTime)后进行回收。
workQueue任务队列
同步队列(SynchronousQueue)
SynchronousQueue是一个特殊的BlockingQueue,它没有容量,没执行一个插入操作就会阻塞,需要再执行一个删除操作才会被唤醒,反之每一个删除操作也都要等待对应的插入操作。
public class ThreadPool {
private static ExecutorService pool;
public static void main( String[] args )
{
//maximumPoolSize设置为2 ,拒绝策略为AbortPolic策略,直接抛出异常
pool = new ThreadPoolExecutor(1, 2, 1000, TimeUnit.MILLISECONDS, new SynchronousQueue<Runnable>(),Executors.defaultThreadFactory(),new ThreadPoolExecutor.AbortPolicy());
for(int i=0;i<3;i++) {
pool.execute(new ThreadTask());
}
}
}
public class ThreadTask implements Runnable{
public ThreadTask() {
}
public void run() {
System.out.println(Thread.currentThread().getName());
}
}
--------------------------打印的结果------------------------------------
pool-1-thread-1
pool-1-thread-2
Exception in thread "main" java.util.concurrent.RejectedExecutionException: Task com.hhxx.test.ThreadTask@55f96302 rejected from java.util.concurrent.ThreadPoolExecutor@3d4eac69[Running, pool size = 2, active threads = 0, queued tasks = 0, completed tasks = 2]
at java.util.concurrent.ThreadPoolExecutor$AbortPolicy.rejectedExecution(Unknown Source)
at java.util.concurrent.ThreadPoolExecutor.reject(Unknown Source)
at java.util.concurrent.ThreadPoolExecutor.execute(Unknown Source)
at com.hhxx.test.ThreadPool.main(ThreadPool.java:17)
可以看到,当任务队列为SynchronousQueue,创建的线程数大于maximumPoolSize时,直接执行了拒绝策略抛出异常。
使用SynchronousQueue队列,提交的任务不会被保存,总是会马上提交执行。如果用于执行任务的线程数量小于maximumPoolSize,则尝试创建新的进程, 如果达到maximumPoolSize设置的最大值,则根据你设置的handler执行拒绝策略。因此这种方式你提交的任务不会被缓存起来,而是会被马上执行, 在这种情况下,你需要对你程序的并发量有个准确的评估,才能设置合适的maximumPoolSize数量,否则很容易就会执行拒绝策略;
有界的任务队列(ArrayBlockingQueue)
有界的任务队列可以使用ArrayBlockingQueue实现,(不是真的无界了,最大是你的内存的最大值)
pool = new ThreadPoolExecutor(1, 2, 1000, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<Runnable>(10),Executors.defaultThreadFactory(),new ThreadPoolExecutor.AbortPolicy());
使用ArrayBlockingQueue有界任务队列,若有新的任务需要执行时,线程池会创建新的线程,直到创建的线程数量达到corePoolSize时, 则会将新的任务加入到等待队列中。若等待队列已满,即超过ArrayBlockingQueue初始化的容量,则继续创建线程, 直到线程数量达到maximumPoolSize设置的最大线程数量,若大于maximumPoolSize,则执行拒绝策略。在这种情况下, 线程数量的上限与有界任务队列的状态有直接关系,如果有界队列初始容量较大或者没有达到超负荷的状态, 线程数将一直维持在corePoolSize以下,反之当任务队列已满时,则会以maximumPoolSize为最大线程数上限。
无界的任务队列(LinkedBlockingQueue)
pool = new ThreadPoolExecutor(1, 2, 1000, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>(),Executors.defaultThreadFactory(),new ThreadPoolExecutor.AbortPolicy());
使用无界任务队列,线程池的任务队列可以无限制的添加新的任务,而线程池创建的最大线程数量就是你corePoolSize设置的数量, 也就是说在这种情况下maximumPoolSize这个参数是无效的,哪怕你的任务队列中缓存了很多未执行的任务,当线程池的线程数达到corePoolSize后, 就不会再增加了;若后续有新的任务加入,则直接进入队列等待,当使用这种任务队列模式时,一定要注意你任务提交与处理之间的协调与控制, 不然会出现队列中的任务由于无法及时处理导致一直增长,直到最后资源耗尽的问题。
优先任务队列(PriorityBlockingQueue)
public class ThreadPool {
private static ExecutorService pool;
public static void main( String[] args )
{
//优先任务队列
pool = new ThreadPoolExecutor(1, 2, 1000, TimeUnit.MILLISECONDS, new PriorityBlockingQueue<Runnable>(),Executors.defaultThreadFactory(),new ThreadPoolExecutor.AbortPolicy());
for(int i=0;i<20;i++) {
pool.execute(new ThreadTask(i));
}
}
}
public class ThreadTask implements Runnable,Comparable<ThreadTask>{
private int priority;
public int getPriority() {
return priority;
}
public void setPriority(int priority) {
this.priority = priority;
}
public ThreadTask() {
}
public ThreadTask(int priority) {
this.priority = priority;
}
//当前对象和其他对象做比较,当前优先级大就返回-1,优先级小就返回1,值越小优先级越高
public int compareTo(ThreadTask o) {
return this.priority>o.priority?-1:1;
}
public void run() {
try {
//让线程阻塞,使后续任务进入缓存队列
Thread.sleep(1000);
System.out.println("priority:"+this.priority+",ThreadName:"+Thread.currentThread().getName());
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
----------------------------打印结果--------------------------------------
priority:0,ThreadName:pool-1-thread-1
priority:9,ThreadName:pool-1-thread-1
priority:8,ThreadName:pool-1-thread-1
priority:7,ThreadName:pool-1-thread-1
priority:6,ThreadName:pool-1-thread-1
priority:5,ThreadName:pool-1-thread-1
priority:4,ThreadName:pool-1-thread-1
priority:3,ThreadName:pool-1-thread-1
priority:2,ThreadName:pool-1-thread-1
priority:1,ThreadName:pool-1-thread-1
通过运行的代码我们可以看出PriorityBlockingQueue它其实是一个特殊的无界队列,它其中无论添加了多少个任务, 线程池创建的线程数也不会超过corePoolSize的数量,只不过其他队列一般是按照先进先出的规则处理任务, 而PriorityBlockingQueue队列可以自定义规则根据任务的优先级顺序先后执行。
线程池的拒绝策略
- AbortPolicy:直接抛出异常,默认策略。
- CallerRunsPolicy:用调用者所在的线程来执行任务。
- DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务。
- DiscardPolicy:直接丢弃任务。
线程池的关闭策略
- shutdown():不会立即终止线程池,要等所有任务缓存队列中的任务都执行完后才终止,但是不会接受新的任务。
- shutdownNow():立即终止线程池,并尝试打断正在执行的任务,并且清空任务缓存队列,返回尚未执行的任务任务。
线程池的阻塞队列
BlockingQueue 是个接口,你需要使用它的实现之一来使用BlockingQueue,Java.util.concurrent包下具有以下 BlockingQueue 接口的实现类:
ArrayBlockingQueue:ArrayBlockingQueue
是一个有界的阻塞队列,其内部实现是将对象放到一个数组里。有界也就意味着,它不能够存储无限多数量的元素。它有一个同一时间能够存储元素数量的上限。你可以在对其初始化的时候设定这个上限,但之后就无法对这个上限进行修改了(译者注:因为它是基于数组实现的,也就具有数组的特性:一旦初始化,大小就无法修改)。DelayQueue:DelayQueue
对元素进行持有直到一个特定的延迟到期。注入其中的元素必须实现 java.util.concurrent.Delayed 接口。LinkedBlockingQueue:LinkedBlockingQueue
内部以一个链式结构(链接节点)对其元素进行存储。如果需要的话,这一链式结构可以选择一个上限。如果没有定义上限,将使用 Integer.MAX_VALUE 作为上限。PriorityBlockingQueue:PriorityBlockingQueue
是一个无界的并发队列。它使用了和类 java.util.PriorityQueue 一样的排序规则。你无法向这个队列中插入 null 值。所有插入到 PriorityBlockingQueue 的元素必须实现 java.lang.Comparable 接口。因此该队列中元素的排序就取决于你自己的 Comparable 实现。SynchronousQueue:SynchronousQueue
是一个特殊的队列,它的内部同时只能够容纳单个元素。如果该队列已有一元素的话,试图向队列中插入一个新元素的线程将会阻塞,直到另一个线程将该元素从队列中抽走。同样,如果该队列为空,试图向队列中抽取一个元素的线程将会阻塞,直到另一个线程向队列中插入了一条新的元素。据此,把这个类称作一个队列显然是夸大其词了。它更多像是一个汇合点LinkedTransferQueue
:由链表结构组成的无界阻塞队列LinkedBlockingDeque
:由链表结构组成的双向阻塞队列
拒绝策略
一般我们创建线程池时,为防止资源被耗尽,任务队列都会选择创建有界任务队列,但种模式下如果出现任务队列已满且线程池创建的线程数达到你设置的最大线程数时, 这时就需要你指定ThreadPoolExecutor的RejectedExecutionHandler参数即合理的拒绝策略,来处理线程池"超载"的情况。ThreadPoolExecutor自带的拒绝策略如下:
- AbortPolicy策略:该策略会直接抛出异常,阻止系统正常工作;
- CallerRunsPolicy策略:如果线程池的线程数量达到上限,该策略会把任务队列中的任务放在调用者线程当中运行;
- DiscardOledestPolicy策略:该策略会丢弃任务队列中最老的一个任务,也就是当前任务队列中最先被添加进去的,马上要被执行的那个任务,并尝试再次提交;
- DiscardPolicy策略:该策略会默默丢弃无法处理的任务,不予任何处理。当然使用此策略,业务场景中需允许任务的丢失;
以上内置的策略均实现了RejectedExecutionHandler接口,当然你也可以自己扩展RejectedExecutionHandler接口,定义自己的拒绝策略, 可以看到由于任务加了休眠阻塞,执行需要花费一定时间,导致会有一定的任务被丢弃,从而执行自定义的拒绝策略;
public class ThreadPool {
private static ExecutorService pool;
public static void main( String[] args )
{
//自定义拒绝策略
pool = new ThreadPoolExecutor(1, 2, 1000, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<Runnable>(5),
Executors.defaultThreadFactory(), new RejectedExecutionHandler() {
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
System.out.println(r.toString()+"执行了拒绝策略");
}
});
for(int i=0;i<10;i++) {
pool.execute(new ThreadTask());
}
}
}
public class ThreadTask implements Runnable{
public void run() {
try {
//让线程阻塞,使后续任务进入缓存队列
Thread.sleep(1000);
System.out.println("ThreadName:"+Thread.currentThread().getName());
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
------------------打印结果-------------------------------
com.hhxx.test.ThreadTask@33909752执行了拒绝策略
com.hhxx.test.ThreadTask@55f96302执行了拒绝策略
com.hhxx.test.ThreadTask@3d4eac69执行了拒绝策略
ThreadName:pool-1-thread-2
ThreadName:pool-1-thread-1
ThreadName:pool-1-thread-1
ThreadName:pool-1-thread-2
ThreadName:pool-1-thread-1
ThreadName:pool-1-thread-2
ThreadName:pool-1-thread-1
# ThreadPoolExecutor扩展
hreadPoolExecutor扩展主要是围绕beforeExecute()、afterExecute()和terminated()三个接口实现的,
- beforeExecute:线程池中任务运行前执行
- afterExecute:线程池中任务运行完毕后执行
- terminated:线程池退出后执行
public class ThreadPool {
private static ExecutorService pool;
public static void main( String[] args ) throws InterruptedException
{
//实现自定义接口
pool = new ThreadPoolExecutor(2, 4, 1000, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<Runnable>(5),
new ThreadFactory() {
public Thread newThread(Runnable r) {
System.out.println("线程"+r.hashCode()+"创建");
//线程命名
Thread th = new Thread(r,"threadPool"+r.hashCode());
return th;
}
}, new ThreadPoolExecutor.CallerRunsPolicy()) {
protected void beforeExecute(Thread t,Runnable r) {
System.out.println("准备执行:"+ ((ThreadTask)r).getTaskName());
}
protected void afterExecute(Runnable r,Throwable t) {
System.out.println("执行完毕:"+((ThreadTask)r).getTaskName());
}
protected void terminated() {
System.out.println("线程池退出");
}
};
for(int i=0;i<10;i++) {
pool.execute(new ThreadTask("Task"+i));
}
pool.shutdown();
}
}
public class ThreadTask implements Runnable{
private String taskName;
public String getTaskName() {
return taskName;
}
public void setTaskName(String taskName) {
this.taskName = taskName;
}
public ThreadTask(String name) {
this.setTaskName(name);
}
public void run() {
//输出执行线程的名称
System.out.println("TaskName"+this.getTaskName()+"---ThreadName:"+Thread.currentThread().getName());
}
}
------------------打印结果-------------------------------
线程118352462创建
线程1550089733创建
准备执行:Task0
准备执行:Task1
TaskNameTask0---ThreadName:threadPool118352462
线程865113938创建
执行完毕:Task0
TaskNameTask1---ThreadName:threadPool1550089733
执行完毕:Task1
准备执行:Task3
TaskNameTask3---ThreadName:threadPool1550089733
执行完毕:Task3
准备执行:Task2
准备执行:Task4
TaskNameTask4---ThreadName:threadPool1550089733
执行完毕:Task4
准备执行:Task5
TaskNameTask5---ThreadName:threadPool1550089733
执行完毕:Task5
准备执行:Task6
TaskNameTask6---ThreadName:threadPool1550089733
执行完毕:Task6
准备执行:Task8
TaskNameTask8---ThreadName:threadPool1550089733
执行完毕:Task8
准备执行:Task9
TaskNameTask9---ThreadName:threadPool1550089733
准备执行:Task7
执行完毕:Task9
TaskNameTask2---ThreadName:threadPool118352462
TaskNameTask7---ThreadName:threadPool865113938
执行完毕:Task7
执行完毕:Task2
线程池退出
可以看到通过对beforeExecute()、afterExecute()和terminated()的实现,我们对线程池中线程的运行状态进行了监控, 在其执行前后输出了相关打印信息。另外使用shutdown方法可以比较安全的关闭线程池, 当线程池调用该方法后,线程池中不再接受后续添加的任务。 但是,此时线程池不会立刻退出,直到添加到线程池中的任务都已经处理完成,才会退出。
# 线程池配置合理参数
CPU密集型任务
配置尽可能少的线程数量:参考公式: 线程池数量=CPU核数+1个线程
I/O密集型任务
由于lO密集型任务线程并不是一直在执行任务,则应配置尽可能多的线程,如CPU核数*2。 IO密集型,即该任务需要大量的IO,即大量的阻塞。在单线程上运行IO密集型的任务会导致浪费大量的CPU运算能力浪费在等待。 所以在IO密集型任务中使用多线程可以大大的加速程序运行,即使在单核CPU上, 这种加速主要就是利用了被浪费掉的阻塞时间IO密集型时,大部分线程都阻塞,故需要多配置线程数:
参考公式:CPU核数l1-阻塞系数(阻塞系数在0.8~0.9之间比如8核CPU:8/1-0.9 = 80个线程数)。
# Callable和Runnable接口的区别
- Callable接口支持返回执行结果,此时需要调用FutureTask.get()方法实现,此方法会阻塞线程直到获取“将来”的结果,当不调用此方法时,主线程不会阻塞。
- Callable接口实现类中run()方法允许将异常向上抛出,也可以直接在内部处理(try. catch); 而Runnable接口实现类中run()方法的异常必须在内部处理掉,不能向上抛出。
# 不用Executors来构建线程池?
用Executors 使得我们不用关心线程池的参数含义,这样可能会导致问题,比如我们用newFixdThreadPool或者newSingleThreadPool
.
允许的队列长度为Integer.MAX_VALUE
,如果使用不当会导致大量请求堆积到队列中导致OOM的风险而newCachedThreadPool,
允许创建线程数量为Integer.MAX_VALUE,也可能会导致大量 线程的创建出现CPU使用过高或者OOM的问题。
而如果我们通过ThreadPoolExecutor来构造线程池的话,我们势必要了解线程池构造中每个参数的具体含义,会更加谨慎。
# 线程池中的核心线程初始化
默认情况下,创建线程池之后,线程池中是没有线程的,需要提交任务之后才会创建线程。 在实际中如果需要线程池创建之后立即创建线程,可以通过如下两个方法:
- prestartCoreThread():初始化一个核心线程。
- prestartAllCoreThreads():初始化所有核心线程
# Happens-Before规则意义
- JMM模型可以通过Happens-Before关系向程序员提供跨线程的内存可见性保证。例如,如果A线程的写操作a与B线程的读操作b之间存在happens-before关系, 尽管a操作和b操作在不同的线程中执行,但JMM向程序员保证a操作将对b操作可见。
- 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
- 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。 如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。
# as-if-serial语义
as-if-serial语义是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。 编译器、runtime和处理器都必须遵守as-if-serial语义。为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序, 因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。
public class AsIfSerialDemo {
public static void main(String[] args) {
int a = 10;//1
int b = 10;//2
int c = a+ b;//3
}
}
1和3之间存在数据依赖关系,同时2和3之间也存在数据依赖关系。因此在最终执行的指令序列中, 3不能被重排序到1和2的前面(3排到1和2的前面,程序的结果将会被改变)。 但1和2之间没有数据依赖关系,编译器和处理器可以重排序1和2之间的执行顺序。
# happens-before与as-if-serial
- happens-before关系保证多线程程序的执行结果不被改变。happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的。
- as-if-serial语义保证单线程内程序的执行结果不被改变。as-if-serial语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。
- as-if-serial语义和happens-before这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。
# JMM内存模型
JMM是共享内存的并发模型,定义程序中各个变量的访问规则。从而实现一个线程对共享变量的写入何时对另一个线程可见,从而保证了变量的可见性。 内存模型是定义了线程和主内存之间的抽象关系。内存区域是指JVMQ运行时将数据分区域存储,强调对内存空间的划分,即运行时数据区(Runtime Data Area)。
# JMM与JVM的区别
JMM是一种规范,定义了线程在JVM主内存中的工作方式,规范了JVM与计算机内存是如何协同工作。 JVM主要是对java内存结构划分管理。是虚拟的一个计算机,具有跨平台性,自动GC,即时编译等特点。
# JMM的八个原子操作
- lock(锁定):作用于主内存的变量,把一个变量标识为线程独占状态。
- unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read (读取):作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
- load(载入):作用于工作内存的变量,它把read操作从主存中变量放入工作内存中。
- use(使用):作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到变量的值,就会使用到这个指令。
- assign(赋值):作用于工作内存中的变量,它把一个从执行引擎中接受到的值放入工作内存的变量副本中。
- store(存储):作用于主内存中的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的write使用。
- write(写入):作用于主内存中的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中
# JMM如何解决有序性问题
原子性问题:除了JVM自身提供的对基本数据类型读写操作的原子性外,可以通过synchronized和volatile实现原子性。 因为synchronized和Lock能够保证任一时刻只有一个线程访问该代码块。
可见性问题:volatile关键字保证可见性。当一个共享变量被volatile修饰时,它会保证修改的值立即被其他的线程看到,即修改的值立即更新到主存中, 当其他线程需要读取时,它会去内存中读取新值。synchronized和Lock也可以保证可见性,因为它们可以保证任一时刻只有一个线程能访问共享资源, 并在其释放锁之前将修改的变量刷新到内存中。
有序性问题:在Java里面,可以通过volatile关键字来保证一定的“有序性”,通过synchronized和Lock来保证有序性,很显然, synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。
Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为happens-before原则: 如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。
# volatile内存屏障
指令重排:为了提高性能,编译器和和处理器通常会对指令进行指令重排序。
图中的三个重排位置可以调换的,根据系统优化需要进行重排。遵循的原则是单线程重排后的执行结果要与顺序执行结果相同。 内存屏障指令:volatile在指令之间插入内存屏障,保证按照特定顺序执行和某些变量的可见性。 volatile就是通过内存屏障通知cpu和编译器不做指令重排优化来维持有序性。
- 写屏障(Store Memory Barrier):告诉处理器在写屏障之前的所有已经存储在存储缓存(store bufferes)中的数据同步到主内存,简单来说就是使得写屏障之前的指令的结果对写屏障之后的读或者写是可见的。
- 读屏障(Load Memory Barrier):处理器在读屏障之后的读操作,都在读屏障之后执行。配合写屏障,使得写屏障之前的内存更新对于读屏障之后的读操作是可见的。
- 全屏障(Full Memory Barrier):确保屏障前的内存读写操作的结果提交到内存之后,再执行屏障后的读写操作。
Java内存模型中volatile变量在写操作之后会插入一个store屏障,在读操作之前会插入一个load屏障。一个类的final字段会在初始化后插入一个store屏障, 来确保final字段在构造函数初始化完成并可被使用时可见。也正是JMM在volatile变量读写前后都插入了内存屏障指令,进而保证了指令的顺序执行。
# 总线锁与缓存锁
总线锁,简单来说就是,在多CPU下,当其中一个处理器要对共享内存进行操作的时候,在总线上发出一个 LOCK信号, 这个信号使得其他处理器无法通过总线来访问到共享内存中的数据,总线锁定把CPU和内存之间的通信锁住了(CPU和内存之间通过总线进行通讯), 这使得锁定期间,其他处理器不能操作其他内存地址的数据。然而这种做法的代价显然太大,那么如何优化呢?优化的办法就是降低锁的粒度, 所以CPU就引入了缓存锁。
缓存锁 的核心机制是基于缓存一致性协议来实现的,一个处理器的缓存回写到内存会导致其他处理器的缓存无效, IA-32处理器和Intel 64处理器使用MESI实现缓存一致性协议(注意,缓存一致性协议不仅仅是通过MESI实现的,不同处理器实现了不同的缓存一致性协议)
# MESI协议原理
MESI是一种广泛使用的支持写回策略的缓存一致性协议,该协议被应用在Intel奔腾系列的CPU中。 MESI协议中的状态:CPU中每个缓存行使用的4种状态进行标记(使用额外的两位bit表示)。
- M(Modified):这行数据有效,数据被修改了,和内存中的数据不一样,数据只存在于本cache中。
- E(Exclusive):这行数据有效,数据和内存中的数据一致,数据只存下于本Cache中
- S(Shared):这行数据有效,数据和内存中的数据一致,数据存在于很多cache中
- I(Invalid):这行数据无效
# MESI协议导致问题
MESI协议虽然可以实现缓存的一致性,但是也会存在一些问题:就是各个CPU缓存行的状态是通过消息传递来进行的。 如果CPU0要对一个在缓存中共享的变量进行写入,首先需要发送一个失效的消息给到其他缓存了该数据的 CPU。 并且要等到他们的确认回执。CPU0在这段时间内都会处于阻塞状态。为了避免阻塞带来的资源浪费。 CPU中又引入了store bufferes:CPU0 只需要在写入共享数据时,直接把数据写入到 store bufferes中, 同时发送invalidate消息,然后继续去处理其他指令(异步) 当收到其他所有 CPU 发送了invalidate acknowledge消息时, 再将store bufferes中的数据数据存储至缓存行中,最后再从缓存行同步到主内存。但是这种优化就会带来了可见性问题, 也可以认为是CPU的乱序执行引起的或者说是指令重排序(指令重排序不仅仅在CPU层面存在,编译器层面也存在指令重排序)。
# Unsafe类的作用
Unsafe提供的API大致可分为Class相关、对象操作相关、内存操作相关、数组操作相关、线程调度相关、CAS相关、内存屏障相关、系统信息获取相关等几类。
- Unsafe 类提供了硬件级别的原子操作。
- Unsafe 类在sun.misc包下,不属于Java标准。Java并发包JUC(java.util.concurrent)和很多Java的基础类库,包括一些被广泛使用的高性能开发库都是基于Unsafe类开发,比如Netty、Hadoop、Kafka 等。
- Unsafe在JUC(java.util.concurrent)包中大量使用(主要是CAS),在netty中方便使用直接内存,还有一些高并发的交易系统为了提高CAS的效率也有可能直接使用到Unsafe。
- Unsafe 可以说时 java 留给开发者的后门,提供了一些低层次操作,如直接内存访问、线程调度等。用于直接操作系统内存且不受 jvm 管辖,实现类似 C++ 风格的操作。
- Unsafe申请的内存的使用将直接脱离jvm,gc将无法管理Unsafe申请的内存,所以使用之后一定要手动释放内存,避免内存溢出!
# Unsafe类的主要功能:
- 类(Class)相关:提供Class和它的静态域操纵方法。
- 信息(Info)相关:返回某些低级别的内存信息。
- 数组(Arrays)相关:提供数组操纵方法。
- 对象(Objects)相关:提供Object和它的域操纵方法。
- 内存(Memory)相关:提供直接内存访问方法(绕过JVM堆直接操纵本地内存)。
- 同步(Synchronization)相关:提供低级别同步原语、线程挂起/放下等操纵方法。
# Unsafe对象的获取
因为是单例模式,所以Unsafe对象不能直接通过 new Unsafe() 来获取,但是也不能通过Unsafe.getUnsafe()获取, 原因是getUnsafe()里有类加载器的判断,只有通过BootStrap classLoader加载的类才能获取,否则都会抛出SecurityException异常。
从getUnsafe方法的使用限制条件出发,通过Java命令行命令-Xbootclasspath/a把调用Unsafe相关方法的类A所在jar包路径追加到默认的bootstrap路径中, 使得A被引导类加载器加载,从而通过Unsafe.getUnsafe方法安全的获取Unsafe实例。
// 其中path为调用Unsafe相关方法的类所在jar包路径
java -Xbootclasspath/a: ${path}
通过将private单例实例暴力“破解”,设置accessible为true,然后通过 Field 的 get 方法,直接获取一个 Object 强制转换为 Unsafe。
private static Unsafe getUnsafe() throws Exception {
Field f = sun.misc.Unsafe.class.getDeclaredField("theUnsafe");
f.setAccessible(true);
return (Unsafe) f.get(null);
}
# ThreadLocal的作用
在处理多线程并发安全的方法中,最常用的方法,就是使用锁,通过锁来控制多个不同线程对临界区的访问。但是,无论是什么样的锁,乐观锁或者悲观锁, 都会在并发冲突的时候对性能产生一定的影响。利用ThreadLocal来实现。ThreadLocal可以解释成线程的局部变量, 也就是说一个ThreadLocal的变量只有当前自身线程可以访问,别的线程都访问不了,那么自然就避免了线程竞争。因此, ThreadLocal提供了一种与众不同的线程安全方式,它不是在发生线程冲突时想办法解决冲突,而是彻底的避免冲突的发生。
# ThreadLocal的理解
ThreadLocal是一种线程隔离机制,它提供了多线程环境下对于共享变量访问的安全性。
在多线程访问共享变量的场景中,一般的解决办法是对共享变量加锁,从而保证在同一时刻只有一个线程能够对共享变量进行更新, 并且基于Happens-Before规则里面的监视器锁规则,又保证了数据修改后对其他线程的可见性。
但是加锁会带来性能的下降,所以ThreadLocal用了一种空间换时间的设计思想,也就是说在每个线程里面,都有一个容器来存储共享变量的副本, 然后每个线程只对自己的变量副本来做更新操作,这样既解决了线程安全问题,又避免了多线程竞争加锁的开销。
ThreadLocal的具体实现原理是,在Thread类里面有一个成员变量ThreadLocalMap,它专门来存储当前线程的共享变量副本, 后续这个线程对于共享变量的操作,都是从这个ThreadLocalMap里面进行变更,不会影响全局共享变量的值。
# ThreadLocal的底层原理
ThreadLocal类用来提供线程内部的局部变量。这种变量在多线程环境下访问(通过get和set方法访问)时能保证各个线程的变量相对独立于其他线程内的变量。 ThreadLocal实例通常来说都是private static类型的,用于关联线程和线程上下文。
ThreadLocal的作用是︰提供线程内的局部变量,不同的线程之间不会相互干扰,这种变量在线程的生命周期内起作用, 减少同一个线程内多个函数或组件之间一些公共变量传递的复杂度。
- 线程并发:在多线程并发的场景下。
- 传递数据:我们可以通过ThreadLoca1在同一线程,不同组件中传递公共变量。
- 线程隔离:每个线程的变量都是独立的,不会互相影响。
# ThreadLocal的内存泄漏问题
虽然ThreadLocalMap中的key是弱引用,当不存在外部强引用的时候,就会自动被回收,但是Entry中的value依然是强引用。这个value的引用链条如下:
可以看到,只有当Thread被回收时,这个value才有被回收的机会,否则,只要线程不退出,value总是会存在一个强引用。但是,要求每个Thread都会退出, 是一个极其苛刻的要求,对于线程池来说,大部分线程会一直存在在系统的整个生命周期内,那样的话,就会造成value对象出现泄漏的可能。 处理的方法是,在ThreadLocalMap进行set(),get(),remove()的时候,都会进行清理。
# ThreadLocal与synchronized的区别
- ThreadLocal采用的是的空间换时间的方式,为每一个线程都提供一份变量副本,从而实现同时访问而不相互干扰。侧重点是:让每一个线程之间的数据相互的隔离。
- synchronized关键字是采用时间换空间的方式,只提供了一份变量,让不同的线程排队访问,侧重点是多个线程之间的资源共享。
# 可重入锁
可重入锁又名递归锁:是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提,锁对象得是同一个对象), 不会因为之前已经获取过还没释放而阻塞。Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。
一个线程中的多个流程可以获取同一把锁,持有这把同步锁可以再次进入。自己可以获取自己的内部锁。
- 隐式锁(即synchronized关键字使用的锁)默认是可重。
- Synchronized的重入锁的实现机理。
- 显式锁(即Lock)也有ReentrantLock这样的可重入锁。
# LockSupport
LockSupport是用于创建锁和其他同步类的基本线程阻塞原语。线程等待唤醒机制。LockSupport中park和unPark作用分别阻塞线程和接触阻塞线程。
- Object类wait和notify方法实现线程等待和唤醒。
- Condition接口await后signal方法实现线程的等待和唤醒。
- 传统的synchronized和lock实现等待唤醒通知的约束。
- LockSupport类中park等待和unpark唤醒。
# 乐观锁与悲观锁
- 悲观锁:当线程去操作数据的时候,总认为别的线程回去修改数据,所以它每次拿数据的时候都会上锁,别的线程去拿数据的时候就会阻塞,比如synchronized。
- 乐观锁:线程去操作数据的时候,认为别的线程不会修改,更新的时候会判断别人是否回去更新数据,通过版本来判断,如果数据被修改了就拒绝更新, 比如CAS是乐观锁,但严格来说并不是锁,通过原子性来保证数据的同步,比如数据库的乐观锁,通过版本控制来实现,但是CAS不会保证线程同步, 乐观的认为在数据更新期间没有其他线程影响。
# 公平锁与非公平锁
公平锁:多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁。
- 优点:所有的线程都能得到资源,不会饿死在队列中。
- 缺点:吞吐量会下降很多,队列里面除了第一个线程,其他的线程都会阻塞,cpu唤醒阻塞线程的开销会很大。
非公平锁:多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。
- 优点:可以减少CPU唤醒线程的开销,整体的吞吐效率会高点,CPU也不必取唤醒所有线程,会减少唤起线程的数量。
- 缺点:你们可能也发现了,这样可能导致队列中间的线程一直获取不到锁或者长时间获取不到锁,导致饿死。
# 共享锁与独占锁、互斥锁
共享锁也叫s锁/读锁,能查看但无法修改和删除的一种数据锁,加锁后其他用户可以并发读取,查询数据,但不能修改,增加,删除数据,读锁可以被多个线程所持有, 该锁可被多个线程持有,用于资源数据共享;
独占锁也是写锁,所以当队列中前驱节点是读锁时且我们设置的读写锁是非公平锁的情况下,我们就可以进行插队操作。
互斥锁也叫做x锁/排它锁/写锁/独占锁/,该锁每一次只能被一个线程锁持有,加锁后任何线程试图再次加锁的线程会被阻塞,直到当前线程解锁, 例子:如果 线程A 对deta1加上排它锁后,则其他线程不能再对data1 加任何类型的锁,获得互斥锁的线既能读取数据又能修改数据。
# CAS算法原理
无锁策略听起来很完美,但是当真正的需要使用时又该如果落地实现呢?而CAS机制则是我们无锁策略的落地实现者,CAS全称Compare And Swap(比较并交换), 而Java中的CAS实现最终也是依赖于CPU的原子性指令实现,在CAS机制中其核心思想如下:CAS(V,E,N):V:需要操作的共享变量、E:预期值、N:新值
由于CAS操作属于乐观派,每次线程操作时都认为自己可以成功执行,当多个线程同时使用CAS操作一个变量时,只有一个会成功执行并成功更新,其余均会失败, 但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。基于这样的原理,CAS机制即使没有锁, 同样也能够得知其他线程对共享资源进行了操作并执行相应的处理措施。同时CAS由于无锁操作中并没有锁的存在,因此不可能出现死锁的情况, 所以也能得出一个结论:“CAS天生免疫死锁”,因为CAS本身没有加锁。
CAS是Java中Unsafe类里面的方法,它的全称是CompareAndSwap,比较并交换的意思。它的主要功能是能够保证在多线程环境下,对于共享变量的修改的原子性。 所以呢,CompareAndSwap的底层实现中,在多核CPU环境下,会增加一个Lock指令对缓存或者总线加锁,从而保证比较并替换这两个指令的原子性。
CAS主要用在并发场景中,比较典型的使用场景有两个。
- 第一个是J.U.C里面Atomic的原子实现,比如AtomicInteger,AtomicLong。
- 第二个是实现多线程对共享资源竞争的互斥性质,比如在AQS、ConcurrentHashMap、ConcurrentLinkedQueue等都有用到。
# CAS导致ABA问题
因为CAS需要在操作值的时候,检查值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么CAS进行检查的时候发现它的值没有发生变化,但是实际上却变化了。
ABA问题的解决思路就是使用版本号:在变量前面加上版本号,每次变量更新的时候把版本号加1,那么A->B->A就会变成1A->2B->3A。从Java 1.5开始, JDK的Atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用, 并且检查当前的标志是否等于预期标志,如果全部相等,则以原子方式将该应用和该标志的值设置为给定的更新值。
# CAS导致CPU飙高原因?
如果JVM能支持处理器提供的pause指令:那么效率会有一定的提升。pause指令有两个作用:第一,它可以延迟流水线执行指令(de-pipeline), 使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零; 第二,它可以避免在循环的时候因内存顺序冲突(Memory Order Violation)而引起CPU流水线被清空,提高CPU的实行效率。
# 多个共享变量的原子操作
当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候可以用锁。 解决方法就是把多个共享变量合并成一个共享变量来操作。比如,有两个共享变量i=2,j=a,合并一下ji=2a,然后用CAS来操作ij。从Java 1.5开始, JDK提供了AtomicReference类来保证引用对象之前的原子性,就可以把多个变量放在一个对象里来进行CAS操作。
# Atomic原子类底层原理
Atomic是基于unsafe类和自旋操作实现的,下面以AtomicInteger类为例进行讲解。CAS全程Compare And Swap ,是条并发原语, 功能是判断内存中某个值是否与预期值相等,相等就用新值更新旧值,否则不更新。Java中CAS是基于unsafe类实现的, 所有的unsafe类中的方法都是native类修饰的,直接调用操作系统底层资源执行响应的任务。
unsafe.compareAndSwapInt(this, valueOffset, expect, update);
# LongAdder原理
Java中的一些Atomic原子类,但是基本都是通过CAS来实现原子性操作,白白浪费CPU资源。 JDK8中新增了一个原子性递增或者递减类LongAdder用来克服高并发下使用AtomicLong的缺点。 LongAdder的思路是把一个变量分解为多个变量,让同样多的线程去竞争多个资源。
使用LongAdder时,内部维护了多个Cell变量,每个Cell里面有一个初始值为0的long型变量,这样同时争取一个变量的线程就变少了, 而是分散成对多个变量的竞争,减少了失败次数。如果竞争某个Cell变量失败,它不会一直在这个Cell变量上自旋CAS重试, 而是尝试在其他的Cell变量上进行CAS尝试,这个改变增加了当前线程重试CAS成功的可能性。最后,在获取LongAdder当前值时, 是把所有Cell变量的value值累加后再加上base返回的。
# CAS为什么会引入本地延迟?
而CAS恰好会导致Cache一致性流量,如果有很多线程都共享同一个对象,当某个Core CAS成功时必然会引起总线风暴,这就是所谓的本地延迟, 本质上偏向锁就是为了消除CAS,降低Cache一致性流量。
# Synchronized原理
在JUC并发编程中synchronized关键字具有非常重要的作用,同时JDK中大量的应用。synchronized,即俗称的对象锁, 它采用互斥的方式让同一时刻至多只有一个线程能持有对象锁,其它线程再想获取这个对象锁时就会阻塞住。 这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换。
- 为了避免临界区的竞态条件发生,有多种手段可以达到目的
- 阻塞式的解决方案:synchronized,Lock
- 非阻塞式的解决方案:原子变量
synchronized的三个作用
- 原子性:确保线程互斥的访问同步代码
- 可见性:保证共享变量的修改能够及时可见
- 有序性:有效解决重排序问题
# java的对象头
在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。如下图所示:
- 实例数据:存放类的属性数据信息,包括父类的属性信息;
- 对齐填充:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐;
- 对象头:Java对象头一般占有2个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit,在64位虚拟机中,1个机器码是8个字节,也就是64bit), 但是 如果对象是数组类型,则需要3个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度.
# Monitor结构原理
Monitor被翻译为监视器或管程,每个Java对象都可以关联一个Monitor对象,如果使用synchronized给对象上锁(重量级)之后,该对象头的Mark Word中就被设置指向Monitor对象的指针。
- 刚开始 Monitor 中 Owner 为 null。
- 当 Thread-2 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor中只能有一个 Owner。
- 在 Thread-2 上锁的过程中,如果 Thread-3,Thread-4,Thread-5 也来执行 synchronized(obj),就会进入EntryList BLOCKED。
- Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争的时是非公平的。
- 图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程。
# synchronized锁的升级
在JDK6之后,锁被优化为无锁、偏向锁、轻量级锁和重量级锁。在编译过程中有锁粗化,锁消除,在运行时有锁升级。
- 锁粗化:如果虚拟机探测到有一系列的连续操作都对同一个对象加锁,甚至加锁操作出现在循环中,那么将会把加锁同步范围扩展到整个操作的外部,这就是锁粗化。
- 锁消除:经过逃逸分析后,发现同步代码块不可能存在共享数据竞争的情况,那么就会将锁消除。逃逸分析,主要是分析对象的动态作用范围,比如在一个方法里一个对象创建后,在调用外部方法时,该对象作为参数传递到其他方法中,成为方法逃逸;当被其他线程访问,如赋值给其他线程中的实例变量,则成为线程逃逸。
- 锁升级:JD6之后分为无锁,偏向锁,轻量级锁,重量级锁。其中偏向锁->轻量级锁->重量级锁的升级过程不可逆。
各种锁并不是相互代替的,而是在不同场景下的不同选择,绝对不是说重量级锁就是不合适的。每种锁是只能升级,不能降级,即由偏向锁->轻量级锁->重量级锁,而这个过程就是开销逐渐加大的过程。
- 如果是单线程使用,那偏向锁毫无疑问代价最小,并且它就能解决问题,连CAS都不用做,仅仅在内存中比较下对象头就可以了;
- 如果出现了其他线程竞争,则偏向锁就会升级为轻量级锁;
- 如果其他线程通过一定次数的CAS尝试没有成功,则进入重量级锁;
锁标志位的表示意义
- 锁标识 lock=00 表示轻量级锁
- 锁标识 lock=10 表示重量级锁
- 偏向锁标识 biased_lock=1表示偏向锁
- 偏向锁标识 biased_lock=0且锁标识=01表示无锁状态
无锁升级为偏向锁
- 线程访问同步代码块,判断锁标识位(01)
- 判断是否偏向锁
- 否,CAS操作替换线程ID
- 成功,获得偏向锁
偏向锁升级为轻量级锁
- 线程访问同步代码块,判断锁标识位(01)
- 判断是否偏向锁
- 是,检查对象头的markword中记录的是否是当前线程ID
- 是,获得偏向锁
- 不是,CAS操作替换线程ID
- 成功,获取偏向锁
- 失败,线程进入阻塞状态,等待原持有线程到达安全点
- 原持有线程到达安全点,检查线程状态
- 已退出同步代码块,释放偏向锁
- 未退出代码块,升级为偏向锁,在原持有线程的栈中分配lock record(锁记录),拷贝对象头中的markword到lock record中,对象头中的markword修改为指向线程中锁记录的指针,升级成功
- 唤醒线程继续执行。
轻量级锁升级为重量级锁
- 线程访问同步代码块,判断锁标识位(00)
- 判断是否轻量级锁
- 是,当前线程的栈中分配lock record
- 拷贝对象头中的markword到lock record中
- CAS操作尝试获取将对象头中的锁记录指针指向当前线程的锁记录
- 成功,当前线程得到轻量级锁
- 执行代码块
- 开始轻量级锁解锁
- CAS操作,判断对象头的锁记录指针是否仍指向当前线程锁记录,拷贝在当前线程锁记录的mark word信息与当前线程的锁记录指针是否一致
- 两个条件都一致,释放锁
- 不一致,释放锁(锁已经升级为重量级锁了),唤醒其他线程
- 5失败,自旋尝试5、
- 自旋过程中成功了,执行6,7,8,9,10,11
- 自旋一定次数仍然失败,升级为重量级锁
# Java对象都能成为锁对象呢?
- Java中的每个对象都派生自Object类,而每个Java Object在JVM内部都有一个native的C++对象 oop/oopDesc进行对应。
- 线程在获取锁的时候,实际上就是获得一个监视器对象(monitor) ,monitor可以认为是一个同步对象,所有的Java对象是天生携带monitor.
# 为什么重量级锁的开销比较大呢?
重量级锁:重量级锁依赖对象内部的monitor锁来实现,而monitor又依赖操作系统的MutexLock(互斥锁) 当系统检查到是重量级锁之后,会把等待想要获取锁的线程阻塞,被阻塞的线程不会消耗CPU,但是阻塞或者唤醒一个线程,都需要通过操作系统来实现, 也就是相当于从用户态转化到内核态,而转化状态是需要消耗时间的
# synchronized锁升级的原理是什么?
在锁对象的对象头里面有一个 threadid 字段,在第一次访问的时候对象头部中的threadid 为空,jvm 让其持有偏向锁,并将 threadid 设置为其线程 id, 再次进入的时候会先判断 threadid 是否与其线程 id 一致,如果一致则可以直接使用此对象,如果不一致,则升级偏向锁为轻量级锁, 通过自旋循环一定次数来获取锁,执行一定次数之后,如果还没有正常获取到要使用的对象,此时就会把锁从轻量级升级为重量级锁,此过程就构成了synchronized锁的升级。
# ReentrantLock原理
ReentrantLock锁是一个轻量级锁,底层其实就是用自旋锁实现的,lock锁不依赖操作系统,而是使用java实现的锁,当我们调用lock方法的时候, 在内部其实调用了Sync.lock()方法,而Sync继承了AbstractQueuedSynchronizer,简称AQS,所以在底层调用的其实是AQS的 lock() 方法;
# 公平锁的实现
ReentrantLock和synchronized不同的是,synchronized在jdk1.5以前是一把很重的锁,每次使用时都需要向操作系统申请,所以会耗费很大的资源,且效率不高, 但ReentrantLock 的底层是由cas实现的,cas本身是自旋锁,也叫无锁,因为轻量级锁不需要像操作系统申请锁资源,所以不会进入阻塞状态,所以lock锁的效率要比synchronized高很多;
# 非公平锁的实现
# AQS的原理
# ReentrantLock与synchronized的区别
synchronized 竞争锁时会一直等待 | ReentrantLock 可以尝试获取锁,并得到获取结果 |
---|---|
synchronized 获取锁无法设置超时 | ReentrantLock 可以设置获取锁的超时时间 |
synchronized 无法实现公平锁 | ReentrantLock 可以满足公平锁,即先等待先获取到锁 |
synchronized 控制等待和唤醒需要结合加锁对象的 wait() 和 notify()、notifyAll() | ReentrantLock 控制等待和唤醒需要结合Condition 的 await() 和 signal()、signalAll() 方法 |
synchronized 是JVM 层面实现的 | ReentrantLock 是 JDK 代码层面实现 |
synchronized 在加锁代码块执行完或者出现异常,自动释放锁 | ReentrantLock 不会自动释放锁,需要在 finally{} 代码块显示释放 |
# ThreadLocal的源码结构?
# ThreadLocal造成内存泄漏的原因?
ThreadLocalMap 中使用的key为ThreadLocal的弱引用,而value是强引用。如果ThreadLocal没有被外部强引用的情况下,在垃圾回收的时候, key会被清理掉,而value不会被清理掉。这样一来,ThreadLocalMap中就会出现key 为null的 Entry。假如我们不做任何措施的话, value永远无法被GC回收,这个时候就可能会产生内存泄露。ThreadLocalMap实现中已经考虑了这种情况,在调用set().get () 、 remove()方法的时候, 会清理掉key 为null 的记录。使用完ThreadLocal方法后最好手动调用remove()方法。
# ThreadLocal内存泄漏解决方案?
每次使用完ThreadLocal,都调用它的remove()方法,清除数据。在使用线程池的情况下,没有及时清理ThreadLocal,不仅是内存泄漏的问题,更严重的是可能导致业务逻辑出现问题。所以使用ThreadLocal就跟加锁完要解锁一样用完就清理。
# 如果对象的引用被置为null,垃圾收集器是否会立即释放对象占用的内存?
不会,在下一个垃圾回调周期中,这个对象将是被可回收的.也就是说并不会立即被垃圾收集器立刻回收,而是在下一次垃圾回收时才会释放其占用的内存。
# sleep()方法与yield()方法有啥区别?
- sleep()方法给其他线程运行机会时不考虑线程的优先级,因此会给低优先级的线程以运行的机会; yield()方法只会给.相凤优先级或更高优先级的线程以运行的机会;
- 线程执行sleep()方法后转入阻塞(blocked)状态,而执行yield()方法后转入就绪(ready)状态;
- sleep()方法声明抛出InterruptedException,而 yield()方法没有声明任何异常;。
- sleep()方法比yield()方法(跟操作系统CPU调度相关)具有更好的可移植性,不建议使用yield()方法来控制并发线程的执行。
# sleep()与wait()的区别?
- 所属类不同: sleep是thread类方法,wait是object类的方法
- 方法类型不用:sleep是静态方法采用类.sleep() 而wait是属性方法 必须使用对象.wait()使用
- 使用语法不同: thread.sleep(),object new=object() synchonized(t){t.wait()}
- 唤醒方式不同: sleep()方法睡眠指定时间之后,线程会自动苏醒。wait()方法被调用后,可以通过notify()或notifyAll()来唤醒wait的线程。
- 释放锁资源不同: sleep()是不释放锁的。wait()是释放锁的。
- 作用不同:sleep()常用于一定时间内暂停线程执行。wait()常用于线程间交互和通信。
# synchronized和volatile的区别
- volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的。
- volatile仅能实现变量的修改可见性,并不能保证原子性;synchronized则可以保证变量的修改可见性和原子性。
- volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。
- volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化。
# lock和synchronized区别
- 从功能角度来看,Lock和Synchronized都是Java中用来解决线程安全问题的工具。
- 从特性来看:Synchronized是Java中的同步关键字,Lock是J.U.C包中提供的接口,这个接口有很多实现类,其中就包括ReentrantLock重入锁, Synchronized可以通过两种方式来控制锁的粒度: 一种是把synchronized关键字修饰在方法层面.另一种是修饰在代码块上,并且我们可以通过Synchronized加锁对象的声明周期来控制锁的作用范围,比如锁对象是静态对象或者类对象,那么这个锁就是全局锁。 如果锁对象是普通实例对象,那这个锁的范围取决于这个实例的声明周期。Lock锁的粒度是通过它里面提供的lock()和unlock()方法决定的,包裹在这两个方法之间的代码能够保证线程安全性。而锁的作用域取决于Lock实例的生命周期。
- Lock比Synchronized的灵活性更高,Lock可以自主决定什么时候加锁,什么时候释放锁,只需要调用lock()和unlock()这两个方法就行, 同时Lock还提供了非阻塞的竞争锁方法tryLock()方法,这个方法通过返回true/false来告诉当前线程是否已经有其他线程正在使用锁。 Synchronized由于是关键字,所以它无法实现非阻塞竞争锁的方法,另外,Synchronized锁的释放是被动的,就是当Synchronized同步代码块执行完以后或者代码出现异常时才会释放。
- Lock提供了公平锁和非公平锁的机制,公平锁是指线程竞争锁资源时,如果已经有其他线程正在排队等待锁释放,那么当前竞争锁资源的线程无法插队。而非公平锁,就是不管是否有线程在排队等待锁,它都会尝试去竞争一次锁。 Synchronized只提供了一种非公平锁的实现。
- 从性能方面来看,Synchronized和Lock在性能方面相差不大,在实现上会有一些区别,Synchronized引入了偏向锁、轻量级锁、重量级锁以及锁升级的方式来优化加锁的性能,而Lock中则用到了自旋锁的方式来实现性能优化。
# 伪共享的原理与解决方案?
这就是伪共享问题的原理:为了提高CPU的利用率,平衡CPU和内存之间的速度差异,在CPU里面设计了三级缓存。CPU在向内存发起IO操作的时候, 一次性会读取64个字节的数据作为一个缓存行,缓存到CPU的高速缓存里面。
在Java中一个long类型是8个字节,意味着一个缓存行可以存储8个long类型的变量。这个设计是基于空间局部性原理来实现的,也就是说, 如果一个存储器的位置被引用,那么将来它附近的位置也会被引用。
所以缓存行的设计对于CPU来说,可以有效的减少和内存的交互次数,从而避免了CPU的IO等待,以提升CPU的利用率。正是因为这种缓存行的设计, 导致如果多个线程修改同一个缓存行里面的多个独立变量的时候,基于缓存一致性协议,就会无意中影响了彼此的性能,这就是伪共享的问题。
像这样一种情况,CPU0上运行的线程想要更新变量X、CPU1上的线程想要更新变量Y,而X/Y/Z都在同一个缓存行里面。
每个线程都需要去竞争缓存行的所有权对变量做更新,基于缓存一致性协议。一旦运行在某个CPU上的线程获得了所有权并执行了修改,就会导致其他CPU中的缓存行失效。
因为伪共享会问题导致缓存锁的竞争,所以在并发场景中的程序执行效率一定会收到较大的影响。这个问题的解决办法有两个:
- 使用对齐填充,因为一个缓存行大小是64个字节,如果读取的目标数据小于64个字节,可以增加一些无意义的成员变量来填充。
- 在Java8里面,提供了@Contented注解,它也是通过缓存行填充来解决伪共享问题的,被@Contented注解声明的类或者字段,会被加载到独立的缓存行上。
# CountDownLatch、CyclicBarrier和Semaphore
- CountDownLatch: 它能够使一个线程再等待一些线程完成各自工作后,再继续执行。使用一个计数器实现,计数器初始值为线程的数量,当每一个线程完成自己任务后,计数器的值会减1,当计数器的值为0时,表示所有的线程都完成了任务,然后CountDownLatch上等待的线程就可以恢复执行任务。
- CyclicBarrier: 让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活,线程进入屏障通过CyclicBarrier的await()方法。
- Semaphore: 信号量主要用于两个目的,一个是用于多个共享资源的互斥使用(单个信号量的Semaphore对象可以实现互斥锁的功能),另一个用于并发线程数的控制,synchronized和重入锁ReentrantLock,这2种锁一次都只能允许一个线程访问一个资源,而信号量可以控制有多少个线程可以访问特定的资源。
Semaphore应用场景
可以用来做流量分流,特别是对公共资源有限的场景,比如数据库连接。假设有这个的需求,读取几万个文件的数据到数据库中,由于文件读取是IO密集型任务, 可以启动几十个线程并发读取,但是数据库连接数只有10个,这时就必须控制最多只有10个线程能够拿到数据库连接进行操作。这个时候,就可以使用Semaphore做流量控制。
# CountDownLatch与CyclicBarrier区别
- CountDownLatch的计数器只能使用一次。而CyclicBarrier的计数器可以使用reset() 方法重置。所以CyclicBarrier能处理更为复杂的业务场景,比如如果计算发生错误,可以重置计数器,并让线程们重新执行一次
- CyclicBarrier还提供getNumberWaiting(可以获得CyclicBarrier阻塞的线程数量)、isBroken(用来知道阻塞的线程是否被中断)等方法。
- CountDownLatch会阻塞主线程,CyclicBarrier不会阻塞主线程,只会阻塞子线程。
# ThreadLocal出现内存泄露吗?
我认为,不恰当的使用ThreadLocal,会造成内存泄漏问题。主要原因是,线程的私有变量ThreadLocalMap里面的key是一个弱引用。 弱引用的特性,就是不管是否存在直接引用关系,当成员ThreadLocal没用其他的强引用关系的时候,这个对象会被GC回收掉。从而导致key可能变成null,造成这块内存永远无法访问,出现内存泄漏的问题。
规避内存泄漏的方法有两个:
- 通过扩大成员变量ThreadLoca的作用域,避免被GC回收
- 每次使用完ThreadLocal以后,调用remove方法移除对应的数据
第一种方法虽然不会造成key为null的现象,但是如果后续线程不再继续访问这个key。也会导致这个内存一直占用不释放,最后造成内存溢出的问题。所以我认为最好是在使用完以后调用remove方法移除。
# ThreadLocal实现原理
ThreadLocal是一个用来解决线程安全性问题的工具。它相当于让每个线程都开辟一块内存空间,用来存储共享变量的副本。 然后每个线程只需要访问和操作自己的共享变量副本即可,从而避免多线程竞争同一个共享资源。它的工作原理很简单,每个线程里面有一个成员变量ThreadLocalMap。 当线程访问用ThreadLocal修饰的共享数据的时候这个线程就会在自己成员变量ThreadLocalMap里面保存一份数据副本。key指向ThreadLocal这个引用,并且是弱引用关系,而value保存的是共享数据的副本。 因为每个线程都持有一个副本,所以就解决了线程安全性问题。
这个问题考察的是内存泄漏,所以必然和对象引用有关系。ThreadLocal中的引用关系如图所示,Thread中的成员变量ThreadLocalMap, 它里面的可以key指向ThreadLocal这个成员变量,并且它是一个弱引用。所谓弱引用,就是说成员变量ThreadLocal允许在这种引用关系存在的情况下,被GC回收。 一旦被回收,key的引用就变成了null,就会导致这个内存永远无法被访问,造成内存泄漏。
那到底ThreadLocal会不会存在内存泄漏呢?从ThreadLocal本身的设计上来看,是一定存在的。 这样理解没问题,但是在实际应用中,我们一般都是使用线程池,而线程池本身是重复利用的所以还是会存在内存泄漏的问题。 除此之外啊,ThreadLocal为了避免内存泄漏问题,当我们在进行数据的读写时,ThreadLocal默认会去尝试做一些清理动作, 找到并清理Entry里面key为null的数据。但是,它仍然不能完全避免,有两个方法可以避免:
- 每次使用完ThreadLocal以后,主动调用remove()方法移除数据。
- 把ThreadLocal声明称全局变量,使得它无法被回收。
# 一个空Object对象的占多大空间?
- 在开启了压缩指针的情况下,Object默认会占用12个字节,但是为了避免伪共享问题,JVM会按照8个字节的倍数进行填充,所以会填充4个字节变成16个字节长度。
- 在关闭压缩指针的情况下,Object默认会占用16个字节,16个字节正好是8的整数倍,因此不需要填充。
在HotSpot 虚拟机里面,一个对象在堆内存里面的内存布局是使用OOP结构来表示的,它主要分为三个部分。
- 对象头,包括Markword、类元指针、数组长度其中Markword用来存储对象运行时的相关数据,比如hashCode、gc分代年龄等。在64位操作系统中占8个字节, 32位操作系统中占4个字节类元指针指向当前实例对象所属哪个类,开启指针压缩的情况下占4个字节,未开启则占8个字节数组长度只有对象数组才会存在,占4个字节
- 实例数据,存储对象中的字段信息
- 对齐填充,Java对象的大小需要按照8个字节或者8个字节的倍数对齐,避免伪共享问题。
因此,一个空的对象,在开启压缩指针的情况下,占16个字节其中Markword占8个字节、类元指针占4个字节, 对齐填充占4个字节。
# 如何让它不进入队列,而是直接启用最大线程数
当我们提交一个任务到线程池的时候,它的工作原理分为四步。
- 第一步,预热核心线程
- 第二步,把任务添加到阻塞队列
- 第三步,如果添加到阻塞队列失败,则创建非核心线程增加处理效率
- 第四步,如果非核心线程数达到了阈值,就触发拒绝策略
所以,如果希望这个任务不进入队列,那么只需要去影响第二步的执行逻辑就行了。Java中线程池提供的构造方法里面,有一个参数可以修改阻塞队列的类型。 其中,就有一个阻塞队列叫SynchronousQueue, 这个队列不能存储任何元素。它的特性是,每生产一个任务,就必须要指派一个消费者来处理,否则就会阻塞生产者。
基于这个特性,只要把线程池的阻塞队列替换成SynchronousQueue。就能够避免任务进入到阻塞队列,而是直接启动最大线程数去处理这个任务。
# 阻塞队列被异步消费怎么保持顺序吗?
首先,阻塞队列本身是符合FIFO特性的队列,也就是存储进去的元素符合先进先出的规则。其次,在阻塞队列里面,使用了condition条件等待来维护了两个等待队列, 一个是队列为空的时候存储被阻塞的消费者另一个是队列满了的时候存储被阻塞的生产者并且存储在等待队列里面的线程,都符合FIFO的特性。
最后,对于阻塞队列的消费过程,有两种情况。
- 第一种,就是阻塞队列里面已经包含了很多任务,这个时候启动多个消费者去消费的时候,它的有序性保证是通过加锁来实现的,也就是每个消费者线程去阻塞队列获取任务的时候必须要先获得排他锁。
- 第二种,如果有多个消费者线程因为阻塞队列中没有任务而阻塞,这个时候这些线程是按照FIFO的顺序存储到condition条件等待队列中的。 当阻塞队列中开始有任务要处理的时候,这些被阻塞的消费者线程会严格按照FIFO的顺序来唤醒,从而保证了消费的顺序型。
# 线程池是如何实现线程复用的?
线程池里面采用了生产者消费者的模式,来实现线程复用。生产者消费者模型,其实就是通过一个中间容器来解耦生产者和消费者的任务处理过程。 生产者不断生产任务保存到容器,消费者不断从容器中消费任务。在线程池里面,因为需要保证工作线程的重复使用,并且这些线程应该是有任务的时候执行, 没任务的时候等待并释放CPU资源。因此它使用了阻塞队列来实现这样一个需求。提交任务到线程池里面的线程称为生产者线程,它不断往线程池里面传递任务。 这些任务会保存到线程池的阻塞队列里面。然后线程池里面的工作线程不断从阻塞队列获取任务去执行。
基于阻塞队列的特性,使得阻塞队列中如果没有任务的时候,这些工作线程就会阻塞等待。直到又有新的任务进来,这些工作线程再次被唤醒。从而达到线程复用的目的。
# Java官方提供了哪几种线程池
JDK中幕刃提供了5中不同线程池的创建方式,下面我分别说一下每一种线程池以及它的特点。
newCachedThreadPool
, 是一种可以缓存的线程池,它可以用来处理大量短期的突发流量。 它的特点有三个,最大线程数是Integer.MaxValue,线程存活时间是60秒,阻塞队列用的是SynchronousQueue,这是一种不存才任何元素的阻塞队列, 也就是每提交一个任务给到线程池,都会分配一个工作线程来处理,由于最大线程数没有限制。所以它可以处理大量的任务,另外每个工作线程又可以存活60s,使得这些工作线程可以缓存起来应对更多任务的处理。newFixedThreadPool
,是一种固定线程数量的线程池。 它的特点是核心线程和最大线程数量都是一个固定的值如果任务比较多工作线程处理不过来,就会加入到阻塞队列里面等待。newSingleThreadExecutor
,只有一个工作线程的线程池。 并且线程数量无法动态更改,因此可以保证所有的任务都按照FIFO的方式顺序执行。newScheduledThreadPool
,具有延迟执行功能的线程池可以用它来实现定时调度newWorkStealingPool
,Java8里面新加入的一个线程池它内部会构建一个ForkJoinPool,利用工作窃取的算法并行处理请求。
这些线程都是通过工具类Executors来构建的,线程池的最终实现类是ThreadPoolExecutor。
# 单例模式设计为什么需要volatile修饰实例对象
我所理解的DCL问题,是在基于双重检查锁设计下的单例模式中,存在不完整对象的问题。而这个不完整对象的本质,是因为指令重排序导致的。
当我们使用instance=new DCLExample()构建一个实例对象的时候,因为new这个操作并不是原子的。所以这段代码最终会被编译成3条指令。
- 为对象分配内存空间
- 初始化对象
- 把实例对象赋值给instance引用
由于这是三个指令并不是原子的。按照重排序规则,在不影响单线程执行结果的情况下,两个不存在依赖关系的指令允许重排序,也就是不一定会按照代码编写顺序来执行。 这样一来就会导致其他线程可能拿到一个不完整的对象,也就是这个instance已经分配了引用实例,但是这个实例的初始化指令还没执行。 解决办法就是可以在instance这个变量上增加一个volatile关键字修饰,volatile底层使用了内存屏障机制来避免指令重排序。
# AQS为什么要使用双向链表?
首先,双向链表的特点是它有两个指针,一个指针指向前置节点,一个指针指向后继节点。所以,双向链表可以支持 常量O(1) 时间复杂度的情况下找到前驱结点,基于这样的特点。 双向链表在插入和删除操作的时候,要比单向链表简单、高效。
因此,从双向链表的特性来看,我认为AQS使用双向链表有三个方面的考虑。
- 第一个方面,没有竞争到锁的线程加入到阻塞队列,并且阻塞等待的前提是,当前线程所在节点的前置节点是正常状态,这样设计是为了避免链表中存在异常线程导致无法唤醒后续线程的问题。 所以线程阻塞之前需要判断前置节点的状态,如果没有指针指向前置节点,就需要从head节点开始遍历,性能非常低。
- 第二个方面,在Lock接口里面有一个,lockInterruptibly()方法,这个方法表示处于锁阻塞的线程允许被中断。也就是说,没有竞争到锁的线程加入到同步队列等待以后 是允许外部线程通过interrupt()方法触发唤醒并中断的。这个时候,被中断的线程的状态会修改成CANCELLED。被标记为CANCELLED状态的线程,是不需要去竞争锁的, 但是它仍然存在于双向链表里面。意味着在后续的锁竞争中,需要把这个节点从链表里面移除,否则会导致锁阻塞的线程无法被正常唤醒。 在这种情况下,如果是单向链表,就需要从Head节点开始往下逐个遍历,找到并移除异常状态的节点。同样效率也比较低,还会导致锁唤醒的操作和遍历操作之间的竞争。
- 第三个方面,为了避免线程阻塞和唤醒的开销,所以刚加入到链表的线程,首先会通过自旋的方式尝试去竞争锁。但是实际上按照公平锁的设计, 只有头节点的下一个节点才有必要去竞争锁,后续的节点竞争锁的意义不大。否则,就会造成羊群效应,也就是大量的线程在阻塞之前尝试去竞争锁带来比较大的性能开销。 所以为了避免这个问题,加入到链表中的节点在尝试竞争锁之前,需要判断前置节点是不是头节点,如果不是头节点,就没必要再去触发锁竞争的动作。 所以这里会涉及到前置节点的查找,如果是单向链表,那么这个功能的实现会非常复杂。
# wait和sleep是否会触发锁的释放以及CPU资源的释放?
- Object.wait()方法,会释放锁资源以及CPU资源。
- Thread.sleep()方法,不会释放锁资源,但是会释放CPU资源。
首先,wait()方法是让一个线程进入到阻塞状态,而这个方法必须要写在一个Synchronized同步代码块里面。 因为wait/notify是基于共享内存来实现线程通信的工具,这个通信涉及到条件的竞争,所以在调用这两个方法之前必须要竞争锁资源。 当线程调用wait方法的时候,表示当前线程的工作处理完了,意味着让其他竞争同一个共享资源的线程有机会去执行。 但前提是其他线程需要竞争到锁资源,所以wait方法必须要释放锁,否则就会导致死锁的问题。
然后,Thread.sleep()方法,只是让一个线程单纯进入睡眠状态,这个方法并没有强制要求加synchronized同步锁。而且从它的功能和语义来说,也没有这个必要。 当然,如果是在一个Synchronized同步代码块里面调用这个Thread.sleep,也并不会触发锁的释放。 最后,凡是让线程进入阻塞状态的方法,操作系统都会重新调度实现CPU时间片切换,这样设计的目的是提升CPU的利用率。
# CompletableFuture的理解
CompletableFuture是JDK1.8里面引入的一个基于事件驱动的异步回调类。简单来说,就是当使用异步线程去执行一个任务的时候,我们希望在任务结束以后触发一个后续的动作。 而CompletableFuture就可以实现这个功能。
举个简单的例子,比如在一个批量支付的业务逻辑里面,涉及到查询订单、支付、发送邮件通知这三个逻辑。 这三个逻辑是按照顺序同步去实现的,也就是先查询到订单以后,再针对这个订单发起支付,支付成功以后再发送邮件通知。而这种设计方式导致这个方法的执行性能比较慢。
所以,这里可以直接使用CompletableFuture,(如图),也就是说把查询订单的逻辑放在一个异步线程池里面去处理。 然后基于CompletableFuture的事件回调机制的特性,可以配置查询订单结束后自动触发支付,支付结束后自动触发邮件通知。 从而极大的提升这个这个业务场景的处理性能!
CompletableFuture提供了5种不同的方式,把多个异步任务组成一个具有先后关系的处理链,然后基于事件驱动任务链的执行。
- 第一种,thenCombine,把两个任务组合在一起,当两个任务都执行结束以后触发事件回调。
- 第二种,thenCompose,把两个任务组合在一起,这两个任务串行执行,也就是第一个任务执行完以后自动触发执行第二个任务。
- 第三种,thenAccept,第一个任务执行结束后触发第二个任务,并且第一个任务的执行结果作为第二个任务的参数,这个方法是纯粹接受上一个任务的结果,不返回新的计算值。
- 第四种,thenApply,和thenAccept一样,但是它有返回值。
- 第五种,thenRun,就是第一个任务执行完成后触发执行一个实现了Runnable接口的任务。
# ReentrantLock是如何实现锁公平和非公平性的 ?
- 公平,指的是竞争锁资源的线程,严格按照请求顺序来分配锁。
- 非公平,表示竞争锁资源的线程,允许插队来抢占锁资源。
ReentrantLock默认采用了非公平锁的策略来实现锁的竞争逻辑。其次,ReentrantLock内部使用了AQS来实现锁资源的竞争, 没有竞争到锁资源的线程,会加入到AQS的同步队列里面,这个队列是一个FIFO的双向链表。
在这样的一个背景下,公平锁的实现方式就是,线程在竞争锁资源的时候判断AQS同步队列里面有没有等待的线程。如果有,就加入到队列的尾部等待。 而非公平锁的实现方式,就是不管队列里面有没有线程等待,它都会先去尝试抢占锁资源,如果抢不到,再加入到AQS同步队列等待。
ReentrantLock和Synchronized默认都是非公平锁的策略,之所以要这么设计,我认为还是考虑到了性能这个方面的原因。 因为一个竞争锁的线程如果按照公平的策略去阻塞等待,同时AQS再把等待队列里面的线程唤醒,这里会涉及到内核态的切换,对性能的影响比较大。 如果是非公平策略,当前线程正好在上一个线程释放锁的临界点抢占到了锁,就意味着这个线程不需要切换到内核态,虽然对原本应该要被唤醒的线程不公平,但是提升了锁竞争的性能。
# AQS是怎么回事儿?
AQS它是J.U.C这个包里面非常核心的一个抽象类,它为多线程访问共享资源提供了一个队列同步器。 在J.U.C这个包里面,很多组件都依赖AQS实现线程的同步和唤醒,比如Lock、Semaphore、CountDownLatch等等。 AQS内部由两个核心部分组成:
- 一个volatile修饰的state变量,作为一个竞态条件
- 用双向链表结构维护的FIFO线程等待队列
它的具体工作原理是,多个线程通过对这个state共享变量进行修改来实现竞态条件,竞争失败的线程加入到FIFO队列并且阻塞,抢占到竞态资源的线程释放之后,后续的线程按照FIFO顺序实现有序唤醒。
AQS里面提供了两种资源共享方式。
- 一种是独占资源,同一个时刻只能有一个线程获得竞态资源。比如ReentrantLock就是使用这种方式实现排他锁
- 另一种是共享资源,同一个时刻,多个线程可以同时获得竞态资源。CountDownLatch或者Semaphore就是使用共享资源的方式,实现同时唤醒多个线程。
# ReentrantLock的实现原理?
什么是ReentrantLock:ReentrantLock是一种可重入的排它锁,主要用来解决多线程对共享资源竞争的问题。
ReentrantLock的特性
它的核心特性有几个:
- 它支持可重入,也就是获得锁的线程在释放锁之前再次去竞争同一把锁的时候,不需要加锁就可以直接访问。
- 它支持公平和非公平特性
- 它提供了阻塞竞争锁和非阻塞竞争锁的两种方法,分别是lock()和tryLock()。
ReentrantLock的实现原理
- 锁的竞争,ReentrantLock是通过互斥变量,使用CAS机制来实现的。
- 没有竞争到锁的线程,使用了AbstractQueuedSynchronizer这样一个队列同步器来存储,底层是通过双向链表来实现的。当锁被释放之后,会从AQS队列里面的头部唤醒下一个等待锁的线程。
- 公平和非公平的特性,主要是体现在竞争锁的时候,是否需要判断AQS队列存在等待中的线程。
- 最后,关于锁的重入特性,在AQS里面有一个成员变量来保存当前获得锁的线程,当同一个线程下次再来竞争锁的时候,就不会去走锁竞争的逻辑,而是直接增加重入次数。
# 什么是可重入?什么是可重入锁?
可重入是多线程并发编程里面一个比较重要的概念,简单来说,就是在运行的某个函数或者代码,因为抢占资源或者中断等原因导致函数或者代码的运行中断, 等待中断程序执行结束后,重新进入到这个函数或者代码中运行,并且运行结果不会受到影响,那么这个函数或者代码就是可重入的。
而可重入锁,简单来说就是一个线程如果抢占到了互斥锁资源,在锁释放之前再去竞争同一把锁的时候,不需要等待,只需要记录重入次数。
在多线程并发编程里面,绝大部分锁都是可重入的,比如Synchronized、ReentrantLock等,但是也有不支持重入的锁,比如JDK8里面提供的读写锁StampedLock。
锁的可重入性,主要解决的问题是避免线程死锁的问题。因为一个已经获得同步锁X的线程,在释放锁X之前再去竞争锁X的时候,相当于会出现自己要等待自己释放锁,这很显然是无法成立的。
# ArrayBlockingQueue原理
- 阻塞队列(BlockingQueue)是在队列的基础上增加了两个附加操作
- 在队列为空的时候,获取元素的线程会等待队列变为非空。
- 当队列满时,存储元素的线程会等待队列可用。
- 由于阻塞队列的特性,可以非常容易实现生产者消费者模型,也就是生产者只需要关心数据的生产,消费者只需要关注数据的消费,所以如果队列满了,生产者就等待,同样,队列空了,消费者也需要等待。
- 要实现这样的一个阻塞队列,需要用到两个关键的技术,队列元素的存储、以及线程阻塞和唤醒。
- 而ArrayBlockingQueue是基于数组结构的阻塞队列,也就是队列元素是存储在一个数组结构里面,并且由于数组有长度限制,为了达到循环生产和循环消费的目的,ArrayBlockingQueue用到了循环数组。
- 而线程的阻塞和唤醒,用到了J.U.C包里面的ReentrantLock和Condition。 Condition相当于wait/notify在JUC包里面的实现。
# wait和notify这个为什么要在synchronized代码块中?
- wait和notify用来实现多线程之间的协调,wait表示让线程进入到阻塞状态,notify表示让阻塞的线程唤醒。
- wait和notify必然是成对出现的,如果一个线程被wait()方法阻塞,那么必然需要另外一个线程通过notify()方法来唤醒这个被阻塞的线程,从而实现多线程之间的通信。
- 在多线程里面,要实现多个线程之间的通信,除了管道流以外,只能通过共享变量的方法来实现,也就是线程t1修改共享变量s,线程t2获取修改后的共享变量s,从而完成数据通信。 但是多线程本身具有并行执行的特性,也就是在同一时刻,多个线程可以同时执行。在这种情况下,线程t2在访问共享变量s之前,必须要知道线程t1已经修改过了共享变量s,否则就需要等待。 同时,线程t1修改过了共享变量S之后,还需要通知在等待中的线程t2。 所以要在这种特性下要去实现线程之间的通信,就必须要有一个竞争条件控制线程在什么条件下等待,什么条件下唤醒。
- 而Synchronized同步关键字就可以实现这样一个互斥条件,也就是在通过共享变量来实现多个线程通信的场景里面,参与通信的线程必须要竞争到这个共享变量的锁资源,才有资格对共享变量做修改,修改完成后就释放锁,那么其他的线程就可以再次来竞争同一个共享变量的锁来获取修改后的数据,从而完成线程之前的通信。
- 所以这也是为什么wait/notify需要放在Synchronized同步代码块中的原因,有了Synchronized同步锁,就可以实现对多个通信线程之间的互斥,实现条件等待和条件唤醒。
- 另外,为了避免wait/notify的错误使用,jdk强制要求把wait/notify写在同步代码块里面,否则会抛出
Illegal MonitorState Exception
- 最后,基于wait/notify的特性,非常适合实现生产者消费者的模型,比如说用wait/notify来实现连接池就绪前的等待与就绪后的唤醒。
# volatile关键字有什么用?
volatile关键字有两个作用。
- 可以保证在多线程环境下共享变量的可见性。
- 通过增加内存屏障防止多个指令之间的重排序。
我理解的可见性,是指当某一个线程对共享变量的修改,其他线程可以立刻看到修改之后的值。 其实这个可见性问题,我认为本质上是由几个方面造成的。
CPU层面的高速缓存,在CPU里面设计了三级缓存去解决CPU运算效率和内存IO效率问题,但是带来的就是缓存的一致性问题,而在多线程并行执行的情况下,缓存一致性就会导致可见性问题。 所以,对于增加了volatile关键字修饰的共享变量,JVM虚拟机会自动增加一个#Lock汇编指令,这个指令会根据CPU型号自动添加总线锁或/缓存锁.
- 总线锁是锁定了CPU的前端总线,从而导致在同一时刻只能有一个线程去和内存通信,这样就避免了多线程并发造成的可见性。
- 缓存锁是对总线锁的优化,因为总线锁导致了CPU的使用效率大幅度下降,所以缓存锁只针对CPU三级缓存中的目标数据加锁,缓存锁是使用MESI缓存一致性来实现的。
指令重排序,所谓重排序,就是指令的编写顺序和执行顺序不一致,在多线程环境下导致可见性问题。 指令重排序本质上是一种性能优化的手段,它来自于几个方面。
- CPU层面,针对MESI协议的更进一步优化去提升CPU的利用率,引入了StoreBuffer机制,而这一种优化机制会导致CPU的乱序执行。当然为了避免这样的问题,CPU提供了内存屏障指令,上层应用可以在合适的地方插入内存屏障来避免CPU指令重排序问题。
- 编译器的优化,编译器在编译的过程中,在不改变单线程语义和程序正确性的前提下,对指令进行合理的重排序优化来提升性能。
所以,如果对共享变量增加了volatile关键字,那么在编译器层面,就不会去触发编译器优化,同时再JVM里面,会插入内存屏障指令来避免重排序问题。 当然,除了volatile以外,从JDK5开始,JMM就使用了一种Happens-Before模型去描述多线程之间的内存可见性问题。 如果两个操作之间具备Happens-Before关系,那么意味着这两个操作具备可见性关系,不需要再额外去考虑增加volatile关键字来提供可见性保障。
# 什么叫做阻塞队列的有界和无界
阻塞队列,是一种特殊的队列,它在普通队列的基础上提供了两个附加功能
- 当队列为空的时候,获取队列中元素的消费者线程会被阻塞,同时唤醒生产者线程。
- 当队列满了的时候,向队列中添加元素的生产者线程被阻塞,同时唤醒消费者线程。
阻塞队列中能够容纳的元素个数,通常情况下是有界的,比如我们实例化一个ArrayBlockingList,可以在构造方法中传入一个整形的数字,表示这个基于数组的阻塞队列中能够容纳的元素个数。这种就是有界队列。
无界队列,就是没有设置固定大小的队列,不过它并不是像我们理解的那种元素没有任何限制,而是它的元素存储量很大,像LinkedBlockingQueue,它的默认队列长度是Integer.Max_Value,所以我们感知不到它的长度限制。
无界队列存在比较大的潜在风险,如果在并发量较大的情况下,线程池中可以几乎无限制的添加任务,容易导致内存溢出的问题!
# 线程池如何知道一个线程的任务已经执行完成
在线程池内部,当我们把一个任务丢给线程池去执行,线程池会调度工作线程来执行这个任务的run方法,run方法正常结束,也就意味着任务完成了。 所以线程池中的工作线程是通过同步调用任务的run()方法并且等待run方法返回后,再去统计任务的完成数量。
线程池外部去获得线程池内部任务的执行状态,有几种方法可以实现。
- 线程池提供了一个
isTerminated()
方法,可以判断线程池的运行状态,我们可以循环判断isTerminated()方法的返回结果来了解线程池的运行状态,一旦线程池的运行状态是Terminated,意味着线程池中的所有任务都已经执行完了。想要通过这个方法获取状态的前提是,程序中主动调用了线程池的shutdown()方法。在实际业务中,一般不会主动去关闭线程池,因此这个方法在实用性和灵活性方面都不是很好。
在线程池中,有一个submit()
方法,它提供了一个Future的返回值,我们通过Future.get()方法来获得任务的执行结果,当线程池中的任务没执行完之前,future.get()方法会一直阻塞,直到任务执行结束。因此,只要future.get()方法正常返回,也就意味着传入到线程池中的任务已经执行完成了!
可以引入一个CountDownLatch计数器
,它可以通过初始化指定一个计数器进行倒计时,其中有两个方法分别是await()阻塞线程,以及countDown()进行倒计时,一旦倒计时归零,所以被阻塞在await()方法的线程都会被释放。
基于这样的原理,我们可以定义一个CountDownLatch对象并且计数器为1,接着在线程池代码块后面调用await()方法阻塞主线程,然后,当传入到线程池中的任务执行完成后,调用countDown()方法表示任务执行结束。
最后,计数器归零0,唤醒阻塞在await()方法的线程。
# Fail-safe机制与Fail-fast机制区别?
Fail-safe和Fail-fast,是多线程并发操作集合时的一种失败处理机制。
Fail-fast: 表示快速失败,在集合遍历过程中,一旦发现容器中的数据被修改了,会立刻抛出ConcurrentModificationException异常,从而导致遍历失败。
定义一个Map集合,使用Iterator迭代器进行数据遍历,在遍历过程中,对集合数据做变更时,就会发生Fail-fast。 java.util包下的集合类都是快速失败机制的, 常见的的使用Fail-fast方式遍历的容器有HashMap和ArrayList等。
Fail-safe:表示失败安全,也就是在这种机制下,出现集合元素的修改,不会抛出ConcurrentModificationException。 原因是采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,
在拷贝的集合上进行遍历。由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,比如这种情况
定义了一个CopyOnWriteArrayList,在对这个集合遍历过程中,对集合元素做修改后,不会抛出异常,但同时也不会打印出增加的元素。 java.util.concurrent包下的容器都是安全失败的,可以在多线程下并发使用,并发修改。 常见的的使用Fail-safe方式遍历的容器有ConcerrentHashMap和CopyOnWriteArrayList等。
# AQS的理解
AQS是多线程同步器,它是J.U.C包中多个组件的底层实现,如Lock、CountDownLatch、Semaphore等都用到了AQS.从本质上来说,AQS提供了两种锁机制,分别是排它锁,和 共享锁。
- 排它锁,就是存在多线程竞争同一共享资源时,同一时刻只允许一个线程访问该共享资源,也就是多个线程中只能有一个线程获得锁资源,比如Lock中的ReentrantLock重入锁实现就是用到了AQS中的排它锁功能。
- 共享锁也称为读锁,就是在同一时刻允许多个线程同时获得锁资源,比如CountDownLatch和Semaphore都是用到了AQS中的共享锁功能。
AQS主要需要解决三个问题:
- 互斥变量的设计与保证多线程修改变量的安全性。
- 未竞争锁资源的线程的等待已经竞争到锁资源的释放后的唤醒。
- 锁竞争的一个公平性与非公平性。
AQS采用了一个int类型的互斥变量state,用来记录锁竞争的一个状态,O表示当前没有任何线程竞争锁资源,大于等于1表示已经有线程正在持有锁资源。 一个线程来尝试获取锁资源,首先判断state是否等于0 如果是等于0,表示无锁状态,则把整个state 修改为1,表示占用锁。 如果在整个过程中多个线程去竞争锁就会导致线程安全问题。因此AQS采用了CAS机制来保证state变量更新的原子性。
没有获得到锁的线程,通过unsafe类中的park方法进行的阻塞,把阻塞的线程按照先进先出的原则加入到AQS的双向链表中。
当获得锁资源线程释放之后,或从双向链表的头部去唤醒下一个等待的线程再去竞争锁。
最后锁竞争的一个公平性与非公平性。在AQS的处理方法是在竞争锁资源的时候,公平锁需要去判断阻塞队列的是否有阻塞线程。如果有,则需要去排队等待,
而非公平锁的处理方式是不管阻塞链表中是否组在阻塞线程,都直接尝试更改互斥变量state去竞争锁。假如持有锁的线程释放锁,当前线程刚好持有锁,那么此时就是表示非公平锁的实现。