程序
程序,是含有指令和数据的文件,被存储在磁盘或其他的数据存储设备中,也就是说程序是静态的代码。
? 进程
进程,是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。简单来说,一个进程就是一个执行中的程序,它在计算机中一个指令接着一个指令地执行着,同时,每个进程还占有某些系统资源如CPU时间,内存空间,文件,文件,输入输出设备的使用权等等。换句话说,当程序在执行时,将会被操作系统载入内存中。
? 线程
线程,与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享同一块内存空间和一组系统资源,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。
三者之间的关系
线程有什么优缺点?
1)好处
2)坏处
你了解守护线程吗?它和非守护线程有什么区别?
Java 中的线程分为两种:守护线程(Daemon)和用户线程(User)。
Thread#setDaemon(boolean on)
设置。true
则把该线程设置为守护线程,反之则为用户线程。Thread#setDaemon(boolean on)
方法,必须在Thread#start()
方法之前调用,否则运行时会抛出异常。唯一的区别是:
程序运行完毕,JVM 会等待非守护线程完成后关闭,但是 JVM 不会等待守护线程。
扩展:Thread Dump 打印出来的线程信息,含有 daemon 字样的线程即为守护进程。可能会有:服务守护进程、编译守护进程、Windows 下的监听 Ctrl + break 的守护进程、Finalizer 守护进程、引用处理守护进程、GC 守护进程。
多线程会共同使用一组计算机上的 CPU ,而线程数大于给程序分配的 CPU 数量时,为了让各个线程都有执行的机会,就需要轮转使用 CPU 。
不同的线程切换使用 CPU 发生的切换数据等,就是上下文切换。
Java 中用到的线程调度算法是什么?
假设计算机只有一个 CPU ,则在任意时刻只能执行一条机器指令,每个线程只有获得 CPU 的使用权才能执行指令。
有两种调度模型:分时调度模型和抢占式调度模型。
分时调度模型是指让所有的线程轮流获得 CPU 的使用权,并且平均分配每个线程占用的 CPU 的时间片这个也比较好理解。
Java 虚拟机采用抢占式调度模型,是指优先让可运行池中优先级高的线程占用 CPU ,如果可运行池中的线程优先级相同,那么就随机选择一个线程,使其占用 CPU 。处于运行状态的线程会一直运行,直至它不得不放弃 CPU 。
如非特别需要,尽量不要用,防止线程饥饿。
什么是线程饥饿?
饥饿,一个或者多个线程因为种种原因无法获得所需要的资源,导致一直无法执行的状态。
Java 中导致饥饿的原因:
? 你对线程优先级的理解是什么?
每一个线程都是有优先级的,一般来说,高优先级的线程在运行时会具有优先权,但这依赖于线程调度的实现,这个实现是和操作系统相关的(OS dependent)。
线程一共有五个状态,分别如下:
新建(new):当创建Thread类的一个实例(对象)时,此线程进入新建状态(未被启动)。例如:Thread t1 = new Thread()
。
可运行(runnable):线程对象创建后,其他线程(比如 main 线程)调用了该对象的 start 方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取 cpu 的使用权。例如:t1.start()
。
有些文章,会称可运行(runnable)为就绪,意思是一样的。
运行(running):线程获得 CPU 资源正在执行任务(#run()
方法),此时除非此线程自动放弃 CPU 资源或者有优先级更高的线程进入,线程将一直运行到结束。
死亡(dead):当线程执行完毕或被其它线程杀死,线程就进入死亡状态,这时线程不可能再进入就绪状态等待执行。
#run()
方法,终止。#stop()
方法,让一个线程终止运行。堵塞(blocked):由于某种原因导致正在运行的线程让出 CPU 并暂停自己的执行,即进入堵塞状态。直到线程进入可运行(runnable)状态,才有机会再次获得 CPU 资源,转到运行(running)状态。阻塞的情况有三种:
正在睡眠:调用 #sleep(long t)
方法,可使线程进入睡眠方式。
一个睡眠着的线程在指定的时间过去可进入可运行(runnable)状态。
正在等待:调用 #wait()
方法。
调用
notify()
方法,回到就绪状态。
被另一个线程所阻塞:调用 #suspend()
方法。
调用
#resume()
方法,就可以恢复。
如下是另外一个图,把阻塞的情况,放在了一起,也可以作为参考:
无意中,又看到一张画的更牛逼的,如下图:
如何结束一个一直运行的线程?
一般来说,有两种方式:
方式一,使用退出标志,这个 flag 变量要多线程可见。
在这种方式中,之所以引入共享变量,是因为该变量可以被多个执行相同任务的线程用来作为是否中断的信号,通知中断线程的执行。
方式二,使用 interrupt 方法,结合 isInterrupted 方法一起使用。
如果一个线程由于等待某些事件的发生而被阻塞,又该怎样停止该线程呢?这种情况经常会发生,比如当一个线程由于需要等候键盘输入而被阻塞,或者调用
Thread#join()
方法,或者Thread#sleep(...)
方法,在网络中调用ServerSocket#accept()
方法,或者调用了DatagramSocket#receive()
方法时,都有可能导致线程阻塞,使线程处于处于不可运行状态时。即使主程序中将该线程的共享变量设置为true
,但该线程此时根本无法检查循环标志,当然也就无法立即中断。这里我们给出的建议是,不要使用
Thread#stop()· 方法,而是使用 Thread 提供的
#interrupt()` 方法,因为该方法虽然不会中断一个正在运行的线程,但是它可以使一个被阻塞的线程抛出一个中断异常,从而使线程提前结束阻塞状态,退出堵塞代码。
所以,方式一和方式二,并不是冲突的两种方式,而是可能根据实际场景下,进行结合。s
一个线程如果出现了运行时异常会怎么样?
如果这个异常没有被捕获的话,这个线程就停止执行了。
另外重要的一点是:如果这个线程持有某个某个对象的监视器,那么这个对象监视器会被立即释放。
Java 中创建线程主要有三种方式:
创建线程的三种方式的对比:
使用方式一
Thread#currentThread()
方法,直接使用 this
即可获得当前线程。使用方式二、或方式三
优点:
线程类只是实现了 Runnable 接口或 Callable 接口,还可以继承其他类。
在这种方式下,多个线程可以共享同一个 target
对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将 CPU、代码和数据分开,形成清晰的模型,较好地体现了面向对象的思想。
Runnable runner = new Runnable(){ ... };
// 通过new Thread(target, name) 方法创建新线程
new Thread(runna,"新线程1").start();
new Thread(runna,"新线程2").start();
【最重要】可以使用线程池。
缺点:编程稍微复杂,如果要访问当前线程,则必须使用Thread#currentThread()
方法。
start 和 run 方法有什么区别?
一个线程运行时发生异常会怎样?
如果异常没有被捕获该线程将会停止执行。Thread.UncaughtExceptionHandler
是用于处理未捕获异常造成线程突然中断情况的一个内嵌接口。当一个未捕获异常将造成线程中断的时候 JVM 会使用 Thread#getUncaughtExceptionHandler()
方法来查询线程的 UncaughtExceptionHandler 并将线程和异常作为参数传递给 handler 的 #uncaughtException(exception)
方法进行处理。
wait + notify 对于大多数胖友,一开始理解可能会比较困难,多看多理解吧。
在 Java 发展史上,曾经使用 suspend、resume 方法对于线程进行阻塞唤醒,但随之出现很多问题,比较典型的还是死锁问题。
解决方案可以使用以对象为目标的阻塞,即利用 Object 类的 wait 和 notify方法实现线程阻塞。
synchronized
块或方法中被调用,并且要保证同步块或方法的锁对象与调用 wait、notify 方法的对象是同一个,如此一来在调用 wait 之前当前线程就已经成功获取某对象的锁,执行 wait 阻塞后当前线程就将之前获取的对象锁释放。具体的实现,看看 《Wait / Notify通知机制解析》 文章。
通过 wait + notify 的组合,可以通知机制,不过我们也可以使用其它工具,胖友可以思考下。例如如下的每一个方式:
Thread类的 sleep 方法和对象的 wait 方法都可以让线程暂停执行,它们有什么区别?
#wait()
方法,会导致当前线程放弃对象的锁(线程暂停执行),进入对象的等待池(wait pool),只有调用对象的 #notify()
方法(或#notifyAll()
方法)时,才能唤醒等待池中的线程进入等锁池(lock pool),如果线程重新获得对象的锁就可以进入就绪状态。? 请说出与线程同步以及线程调度相关的方法?
notify 和 notifyAll 有什么区别?
当一个线程进入 wait 之后,就必须等其他线程 notify/notifyAll 。
为什么 wait, notify 和 notifyAll 这三方法不在 Thread 类里面?
一个很明显的原因是 Java 提供的锁是对象级的而不是线程级的,每个对象都有锁,通过线程获得。
由于 wait,notify 和 notifyAll 方法都是锁级别的操作,所以把它们定义在 Object 类中,因为锁属于对象。
为什么 wait 和 notify 方法要在同步块中调用?
为什么你应该在循环中检查等待条件?
处于等待状态的线程可能会收到错误警报和伪唤醒,如果不在循环中检查等待条件,程序就会在没有满足结束条件的情况下退出。
所以,我们不能写 if (condition)
而应该是 while (condition)
,特别是 CAS 竞争的时候。
1)sleep 方法
在指定的毫秒数内,让当前正在执行的线程休眠(暂停执行),此操作受到系统计时器和调度程序精度和准确性的影响。让其他线程有机会继续执行,但它并不释放对象锁。也就是如果有synchronized
同步块,其他线程仍然不能访问共享数据。注意该方法要捕获异常。
比如有两个线程同时执行(没有 synchronized
),一个线程优先级为MAX_PRIORITY
,另一个为 MIN_PRIORITY
。
#sleep(5000)
后,低优先级就有机会执行了。2)yield 方法
yield 方法和 sleep 方法类似,也不会释放“锁标志”,区别在于:
3)join 方法
Thread 的非静态方法 join ,让一个线程 B “加入”到另外一个线程 A 的尾部。在线程 A 执行完毕之前,线程 B 不能工作。示例代码如下:
Thread t = new MyThread();
t.start();
t.join();
t
完成为止。然而,如果它加入的线程 t
没有存活,则当前线程不需要停止。线程的 sleep 方法和 yield 方法有什么区别?
为什么 Thread 类的 sleep 和 yield 方法是静态的?
Thread 类的 sleep 和 yield 方法,将在当前正在执行的线程上运行。所以在其他处于等待状态的线程上调用这些方法是没有意义的。这就是为什么这些方法是静态的。它们可以在当前正在执行的线程中工作,并避免程序员错误的认为可以在其他非运行线程调用这些方法。
? sleep(0) 有什么用途?
Thread#sleep(0)
方法,并非是真的要线程挂起 0 毫秒,意义在于这次调用 Thread#sleep(0)
方法,把当前线程确实的被冻结了一下,让其他线程有机会优先执行。Thread#sleep(0)
方法,是你的线程暂时放弃 CPU ,也就是释放一些未用的时间片给其他线程或进程使用,就相当于一个让位动作。
你如何确保 main 方法所在的线程是 Java 程序最后结束的线程?
考点,就是 join 方法。
我们可以使用 Thread 类的 #join()
方法,来确保所有程序创建的线程在 main 方法退出前结束。
1)interrupt 方法
Thread#interrupt()
方法,用于中断线程。调用该方法的线程的状态为将被置为”中断”状态。
注意:线程中断仅仅是置线程的中断状态位,不会停止线程。需要用户自己去监视线程的状态为并做处理。支持线程中断的方法(也就是线程中断后会抛出 InterruptedException 的方法)就是在监视线程的中断状态,一旦线程的中断状态被置为“中断状态”,就会抛出中断异常。
2)interrupted
Thread#interrupted()
静态方法,查询当前线程的中断状态,并且清除原状态。如果一个线程被中断了,第一次调用 #interrupted()
方法则返回 true
,第二次和后面的就返回 false
了。
// Thread.java
public static boolean interrupted() {
return currentThread().isInterrupted(true); // 清理
}
private native boolean isInterrupted(boolean ClearInterrupted);
3)interrupted
Thread#isInterrupted()
方法,查询指定线程的中断状态,不会清除原状态。代码如下:
// Thread.java
public boolean isInterrupted() {
return isInterrupted(false); // 不清楚
}
private native boolean isInterrupted(boolean ClearInterrupted);
线程安全,是编程中的术语,指某个函数、函数库在多线程环境中被调用时,能够正确地处理多个线程之间的共享变量,使程序功能正确完成。
? Servlet 是线程安全吗?
Servlet 不是线程安全的,Servlet 是单实例多线程的,当多个线程同时访问同一个方法,是不能保证共享变量的线程安全性的。
? Struts2 是线程安全吗?
Struts2 的 Action 是多实例多线程的,是线程安全的,每个请求过来都会 new
一个新的 Action 分配给这个请求,请求完成后销毁。
? SpringMVC 是线程安全吗?
不是的,和 Servlet 类似的处理流程。
? 单例模式的线程安全性?
老生常谈的问题了,首先要说的是单例模式的线程安全意味着:某个类的实例在多线程环境下只会被创建一次出来。单例模式有很多种的写法,我总结一下:
1)线程同步
线程同步,是指线程之间所具有的一种制约关系,一个线程的执行依赖另一个线程的消息,当它没有得到另一个线程的消息时应等待,直到消息到达时才被唤醒。
线程间的同步方法,大体可分为两类:用户模式和内核模式。顾名思义:
2)线程互斥
线程互斥,是指对于共享的进程系统资源,在各单个线程访问时的排它性。
? 如何在两个线程间共享数据?
在两个线程间共享变量,即可实现共享。
一般来说,共享变量要求变量本身是线程安全的,然后在线程内使用的时候,如果有对共享变量的复合操作,那么也得保证复合操作的线程安全性。
? 怎么检测一个线程是否拥有锁?
调用 Thread#holdsLock(Object obj)
静态方法,它返回 true
如果当且仅当当前线程拥有某个具体对象的锁。代码如下:
// Thread.java
public static native boolean holdsLock(Object obj);
? 10 个线程和 2 个线程的同步代码,哪个更容易写?
从写代码的角度来说,两者的复杂度是相同的,因为同步代码与线程数量是相互独立的。
但是同步策略的选择依赖于线程的数量,因为越多的线程意味着更大的竞争,所以你需要利用同步技术,如锁分离,这要求更复杂的代码和专业知识。
ThreadLocal ,是 Java 里一种特殊的变量。每个线程都有一个 ThreadLocal 就是每个线程都拥有了自己独立的一个变量,竞争条件被彻底消除了。
它是为创建代价高昂的对象获取线程安全的好方法,比如你可以用 ThreadLocal 让 SimpleDateFormat 变成线程安全的,因为那个类创建代价高昂且每次调用都需要创建不同的实例所以不值得在局部范围使用它,如果为每个线程提供一个自己独有的变量拷贝,将大大提高效率。
? 所以,ThreadLocal 很适合实现线程级的单例。
什么是 InheritableThreadLocal ?
InheritableThreadLocal 类,是 ThreadLocal 类的子类。ThreadLocal 中每个线程拥有它自己的值,与 ThreadLocal 不同的是,InheritableThreadLocal 允许一个线程以及该线程创建的所有子线程都可以访问它保存的值。
在多线程环境下,SimpleDateFormat 是线程安全的吗?
不是,非常不幸,DateFormat 的所有实现,包括 SimpleDateFormat 都不是线程安全的,因此你不应该在多线程序中使用,除非是在对外线程安全的环境中使用,如将 SimpleDateFormat 限制在 ThreadLocal 中。
如果你不这么做,在解析或者格式化日期的时候,可能会获取到一个不正确的结果。因此,从日期、时间处理的所有实践来说,我强力推荐 joda-time 库。
kill -3 [java pid]
不会在当前终端输出,它会输出到代码执行的或指定的地方去。比如, kill -3 tomcat pid
, 输出堆栈到 log 目录下。
Jstack [java pid]
这个比较简单,在当前终端显示,也可以重定向到指定文件中。
JVisualVM:Thread Dump
不做说明,打开 JVisualVM 后,都是界面操作,过程还是很简单的。
java.util.Timer
,是一个工具类,可以用于安排一个线程在未来的某个特定时间执行。Timer 类可以用安排一次性任务或者周期任务。
java.util.TimerTask
,是一个实现了 Runnable 接口的抽象类,我们需要去继承这个类来创建我们自己的定时任务并使用 Timer 去安排它的执行。
目前有开源的 Qurtz 可以用来创建定时任务。
1、给线程命名。
这样可以方便找 bug 或追踪。OrderProcessor、QuoteProcessor、TradeProcessor 这种名字比 Thread-1、Thread-2、Thread-3 好多了,给线程起一个和它要完成的任务相关的名字,所有的主要框架甚至JDK都遵循这个最佳实践。
2、最小化同步范围。
锁花费的代价高昂且上下文切换更耗费时间空间,试试最低限度的使用同步和锁,缩小临界区。因此相对于同步方法我更喜欢同步块,它给我拥有对锁的绝对控制权。
3、优先使用 volatile
,而不是 synchronized
。
4、尽可能使用更高层次的并发工具而非 wait 和 notify 方法来实现线程通信。
首先,CountDownLatch, Semaphore, CyclicBarrier 和 Exchanger 这些同步类简化了编码操作,而用 wait 和 notify 很难实现对复杂控制流的控制。
其次,这些类是由最好的企业编写和维护在后续的 JDK 中它们还会不断优化和完善,使用这些更高等级的同步工具你的程序可以不费吹灰之力获得优化。
5、优先使用并发容器,而非同步容器。
这是另外一个容易遵循且受益巨大的最佳实践,并发容器比同步容器的可扩展性更好,所以在并发编程时使用并发集合效果更好。如果下一次你需要用到 Map ,我们应该首先想到用 ConcurrentHashMap 类。
6、考虑使用线程池。
并发(Concurrency)和并行(Parallellism)是:
所以并发编程的目标是,充分的利用处理器的每一个核,以达到最高的处理性能。
如果数据将在线程间共享。例如正在写的数据以后可能被另一个线程读到,或者正在读的数据可能已经被另一个线程写过了,那么这些数据就是共享数据,必须进行同步存取。
当应用程序在对象上调用了一个需要花费很长时间来执行的方法,并且不希望让程序等待方法的返回时,就应该使用异步编程,在很多情况下采用异步途径往往更有效率。
当然,如果我们对效率没有特别大的要求,也不一定需要使用异步编程,因为它会带来编码的复杂性。总之,合适才是正确的。
synchronized
的原理是什么?synchronized
是 Java 内置的关键字,它提供了一种独占的加锁方式。
synchronized
的获取和释放锁由JVM实现,用户不需要显示的释放锁,非常方便。synchronized
也有一定的局限性。当线程尝试获取锁的时候,如果获取不到锁会一直阻塞。如果获取锁的线程进入休眠或者阻塞,除非当前线程异常,否则其他线程尝试获取锁必须一直等待。关于原理,直接阅读 《【死磕 Java 并发】—– 深入分析 synchronized 的实现原理》 文章,有几个重点要注意看。
当一个线程进入某个对象的一个 synchronized 的实例方法后,其它线程是否可进入此对象的其它方法?
synchronized
的话,其他线程是可以进入的。? 同步方法和同步块,哪个是更好的选择?
同步块是更好的选择,因为它不会锁住整个对象(当然你也可以让它锁住整个对象)。同步方法会锁住整个对象,哪怕这个类中有多个不相关联的同步块,这通常会导致他们停止执行并需要等待获得这个对象上的锁。
同步块更要符合开放调用的原则,只在需要锁住的代码块锁住相应的对象,这样从侧面来说也可以避免死锁。
? 在监视器(Monitor)内部,是如何做线程同步的?
监视器和锁在 Java 虚拟机中是一块使用的。监视器监视一块同步代码块,确保一次只有一个线程执行同步代码块。每一个监视器都和一个对象引用相关联。线程在获取锁之前不允许执行同步代码。
Java 如何实现“自旋”(spin)
参考 《Java 锁的种类以及辨析(一):自旋锁》
代码如下:
public class SpinLock {
private AtomicReference<Thread> sign =new AtomicReference<>();
public void lock() { // <1>
Thread current = Thread.currentThread();
while(!sign .compareAndSet(null, current)) {
// <1.1>
}
}
public void unlock () { // <2>
Thread current = Thread.currentThread();
sign .compareAndSet(current, null);
}
}
<1>
处,#lock()
方法,如果获得不到锁,就会“死循环”,直到或得到锁为止。考虑到“死循环”会持续占用 CPU ,可能导致其它线程无法获得到 CPU 执行,可以在 <1.1>
处增加 Thread.yiead()
代码段,出让下 CPU 。<2>
处,#unlock()
方法,释放锁。volatile
涉及的内容,其实蛮多的,所以胖友直接看:
? volatile 有什么用?
volatile
保证内存可见性和禁止指令重排。
同时,
volatile
可以提供部分原子性。
简单来说,volatile
用于多线程环境下的单次操作(单次读或者单次写)。
? volatile 变量和 atomic 变量有什么不同?
volatile
变量,可以确保先行关系,即写操作会发生在后续的读操作之前,但它并不能保证原子性。例如用 volatile
修饰 count
变量,那么 count++
操作就不是原子性的。#getAndIncrement()
方法,会原子性的进行增量操作把当前值加一,其它数据类型和引用变量也可以进行相似操作。? 可以创建 volatile 数组吗?
Java 中可以创建 volatile
类型数组,不过只是一个指向数组的引用,而不是整个数组。如果改变引用指向的数组,将会受到 volatile
的保护,但是如果多个线程同时改变数组的元素,volatile
标示符就不能起到之前的保护作用了。
同理,对于 Java POJO 类,使用 volatile
修饰,只能保证这个引用的可见性,不能保证其内部的属性。
volatile 能使得一个非原子操作变成原子操作吗?
一个典型的例子是在类中有一个 long
类型的成员变量。如果你知道该成员变量会被多个线程访问,如计数器、价格等,你最好是将其设置为 volatile
。为什么?因为 Java 中读取 long
类型变量不是原子的,需要分成两步,如果一个线程正在修改该 long
变量的值,另一个线程可能只能看到该值的一半(前 32 位)。但是对一个 volatile
型的 long
或 double
变量的读写是原子。
如下的内容,可以作为上面的内容的补充。
一种实践是用
volatile
修饰long
和double
变量,使其能按原子类型来读写。double
和long
都是64位宽,因此对这两种类型的读是分为两部分的,第一次读取第一个 32 位,然后再读剩下的 32 位,这个过程不是原子的,但 Java 中volatile
型的long
或double
变量的读写是原子的。
volatile 类型变量提供什么保证?
volatile
主要有两方面的作用:
例如,JVM 或者 JIT 为了获得更好的性能会对语句重排序,但是 volatile
类型变量即使在没有同步块的情况下赋值也不会与其他语句重排序。
volatile
提供 happens-before 的保证,确保一个线程的修改能对其他线程是可见的。volatile
还能提供原子性,如读 64 位数据类型,像 long
和 double
都不是原子的(低 32 位和高 32 位),但 volatile
类型的 double
和 long
就是原子的。不过需要在 64 位的 JVM 虚拟机上。详细的分析,可以看看 《Java中 long 和 double 的原子性》 。volatile 和 synchronized 的区别?
volatile
本质是在告诉 JVM 当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取。synchronized
则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。volatile
仅能使用在变量级别。synchronized
则可以使用在变量、方法、和类级别的。volatile
仅能实现变量的修改可见性,不能保证原子性。而synchronized
则可以保证变量的修改可见性和原子性。volatile
不会造成线程的阻塞。synchronized
可能会造成线程的阻塞。volatile
标记的变量不会被编译器优化。synchronized
标记的变量可以被编译器优化。另外,会有面试官会问
volatile
能否取代synchronized
呢?答案肯定是不能,虽然说volatile
被称之为轻量级锁,但是和synchronized
是有本质上的区别,原因就是上面的几点落。
? 什么场景下可以使用 volatile 替换 synchronized ?
volatile
替代,synchronized
保证可操作的原子性一致性和可见性。volatile
适用于新值不依赖于旧值的情形。volatile
。死锁,是指两个或两个以上的进程(或线程)在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。
产生死锁的必要条件:
死锁的解决方法:
? 什么是活锁?
活锁,任务或者执行者没有被阻塞,由于某些条件没有满足,导致一直重复尝试,失败,尝试,失败。
? 死锁与活锁的区别?
活锁和死锁的区别在于,处于活锁的实体是在不断的改变状态,所谓的“活”,而处于死锁的实体表现为等待;活锁有可能自行解开,死锁则不能。
实际上,聪慧的胖友是不是已经发现,死锁就是悲观锁可能产生的结果,而活锁是乐观锁可能产生的结果。
1)悲观锁
悲观锁,总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。
synchronized
关键字的实现也是悲观锁。2)乐观锁
乐观锁,顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量。
像数据库提供的类似于 write_condition 机制,其实都是提供的乐观锁。
例如,version 字段(比较跟上一次的版本号,如果一样则更新,如果失败则要重复读-比较-写的操作)
在 Java 中 java.util.concurrent.atomic
包下面的原子变量类就是使用了乐观锁的一种实现方式 CAS 实现的。
乐观锁的实现方式:
艿艿:虽然 Lock 也翻译成锁,但是和上面的 「Java 锁」 分开,它更多强调的是
synchronized
和volatile
关键字带来的重量级和轻量级锁。而 Lock 是 Java 锁接口,提供了更多灵活的功能。
java.util.concurrent.locks.Lock
接口,比 synchronized
提供更具拓展行的锁操作。它允许更灵活的结构,可以具有完全不同的性质,并且可以支持多个相关类的条件对象。它的优势有:
举例来说明锁的可重入性。代码如下:
public class UnReentrant{
Lock lock = new Lock();
public void outer() {
lock.lock();
inner();
lock.unlock();
}
public void inner() {
lock.lock();
//do something
lock.unlock();
}
}
#outer()
方法中调用了 #inner()
方法,#outer()
方法先锁住了 lock
,这样 #inner()
就不能再获取 lock
。#outer()
方法的线程已经获取了 lock
锁,但是不能在 #inner()
方法中重复利用已经获取的锁资源,这种锁即称之为不可重入。synchronized
、ReentrantLock 都是可重入的锁,可重入锁相对来说简化了并发编程的开发。
关于 ReentrantLock 类,详细的源码解析,可以看看 《【死磕 Java 并发】—– J.U.C 之重入锁:ReentrantLock》 。
简单来说,ReenTrantLock 的实现是一种自旋锁,通过循环调用 CAS 操作来实现加锁。它的性能比较好也是因为避免了使线程进入内核态的阻塞状态。想尽办法避免线程进入内核的阻塞状态是我们去分析和理解锁设计的关键钥匙。
? synchronized 和 ReentrantLock 异同?
synchronized
通过 Java 对象头锁标记和 Monitor 对象实现同步。synchronized
依赖 JVM 内存模型保证包含共享变量的多线程内存可见性。volatile state
保证包含共享变量的多线程内存可见性。synchronized
可以修饰实例方法(锁住实例对象)、静态方法(锁住类对象)、代码块(显示指定锁对象)。finally
块中释放锁。synchronized
不可设置等待时间、不可被中断(interrupted)。synchronized
只支持非公平锁。在
synchronized
优化以前,它的性能是比 ReenTrantLock 差很多的,但是自从synchronized
引入了偏向锁,轻量级锁(自旋锁)后,两者的性能就差不多了,在两种方法都可用的情况下,官方甚至建议使用synchronized
。并且,实际代码实战中,可能的优化场景是,通过读写分离,进一步性能的提升,所以使用 ReentrantReadWriteLock 。?
ReadWriteLock ,读写锁是,用来提升并发程序性能的锁分离技术的 Lock 实现类。可以用于 “多读少写” 的场景,读写锁支持多个读操作并发执行,写操作只能由一个线程来操作。
ReadWriteLock 对向数据结构相对不频繁地写入,但是有多个任务要经常读取这个数据结构的这类情况进行了优化。ReadWriteLock 使得你可以同时有多个读取者,只要它们都不试图写入即可。如果写锁已经被其他任务持有,那么任何读取者都不能访问,直至这个写锁被释放为止。
ReadWriteLock 对程序性能的提高主要受制于如下几个因素:
ReadWriteLock 的源码解析,可以看看 《【死磕 Java 并发】—– J.U.C 之读写锁:ReentrantReadWriteLock》 。
在没有 Lock 之前,我们使用 synchronized
来控制同步,配合 Object 的 #wait()
、#notify()
等一系列方法可以实现等待 / 通知模式。在 Java SE 5 后,Java 提供了 Lock 接口,相对于 synchronized
而言,Lock 提供了条件 Condition ,对线程的等待、唤醒操作更加详细和灵活。下图是 Condition 与 Object 的监视器方法的对比(摘自《Java并发编程的艺术》):
? 用三个线程按顺序循环打印 abc 三个字母,比如 abcabcabc ?
synchronized
+ await/notifyAll 来实现,参看 《Java用三个线程按顺序循环打印 abc 三个字母,比如 abcabcabc》 。LockSupport 是 JDK 中比较底层的类,用来创建锁和其他同步工具类的基本线程阻塞。
LockSupport#park()
和 LockSupport#unpark()
方法,来实现线程的阻塞和唤醒的。对于 LockSupport 了解即可,面试一般问的不多。感兴趣的胖友,可以看看如下文章:
关于 Java 内存模型,涉及的内容会很多,所以建议胖友看如下的 《深入Java内存模型.pdf》 这本小书。
然后,看完之后你肯定会忘记,就可以靠 《《深入理解 Java 内存模型》读书笔记》 来补刀。
再另外,《深入拆解 Java 虚拟机》 的 「第五部分 高效并发」 也推荐阅读。
Java 虚拟机规范中试图定义一种 Java 内存模型(Java Memory Model,JMM)来屏蔽掉各层硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。
Java 内存模型规定了所有的变量都存储在主内存(Main Memory)中。每条线程还有自己的工作内存(Working Memory),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间的变量值的传递均需要通过主内存来完成,线程、主内存、工作内存三者的关系如下图:
艿艿:当然,有个面试官会把 Java 内存模型,和 JVM 内存结构搞混淆。所以,在回答之前,可以先和面试官确认下说的是哪个。
关于 JVM 内存结构的面试题,我们在 《精尽 Java【虚拟机】面试题》 中在详细分享。
线程之间的通信方式,目前有共享内存和消息传递两种。
1)共享内存
在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信。典型的共享内存通信方式,就是通过共享对象进行通信。
例如上图线程 A 与 线程 B 之间如果要通信的话,那么就必须经历下面两个步骤:
2)消息传递
在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信。在 Java 中典型的消息传递方式,就是 #wait()
和 #notify()
,或者 BlockingQueue 。
在执行程序时,为了提供性能,处理器和编译器常常会对指令进行重排序,但是不能随意重排序,不是你想怎么排序就怎么排序,它需要满足以下两个条件:
需要注意的是:重排序不会影响单线程环境的执行结果,但是会破坏多线程的执行语义。
详细看 《【死磕 Java 并发】—– Java 内存模型之 happens-before》 文章。
内存屏障,又称内存栅栏,是一组处理器指令,用于实现对内存操作的顺序限制。
? 内存屏障为何重要?
对主存的一次访问一般花费硬件的数百次时钟周期。处理器通过缓存(caching)能够从数量级上降低内存延迟的成本这些缓存为了性能重新排列待定内存操作的顺序。也就是说,程序的读写操作不一定会按照它要求处理器的顺序执行。当数据是不可变的,同时/或者数据限制在线程范围内,这些优化是无害的。如果把这些优化与对称多处理(symmetric multi-processing)和共享可变状态(shared mutable state)结合,那么就是一场噩梦。
当基于共享可变状态的内存操作被重新排序时,程序可能行为不定。一个线程写入的数据可能被其他线程可见,原因是数据写入的顺序不一致。适当的放置内存屏障,通过强制处理器顺序执行待定的内存操作来避免这个问题。
何为同步容器?可以简单地理解为通过 synchronized
来实现同步的容器,如果有多个线程调用同步容器的方法,它们将会串行执行。
Collections#synchronizedSet()
,Collections#synchronizedList()
等方法返回的容器。synchronized
。并发容器,使用了与同步容器完全不同的加锁策略来提供更高的并发性和伸缩性。
new
新的数据从而不影响原有的数据,iterator 完成后再将头指针替换为新的数据 ,这样 iterator 线程可以使用原来老的数据,而写线程也可以并发的完成改变。关于 ConcurrentHashMap 的源码解析,推荐胖友看看如下两篇文章:
? Java 中 ConcurrentHashMap 的并发度是什么?
在 JDK8 前,ConcurrentHashMap 把实际 map 划分成若干部分来实现它的可扩展性和线程安全。这种划分是使用并发度获得的,它是 ConcurrentHashMap 类构造函数的一个可选参数,默认值为 16 ,这样在多线程情况下就能避免争用。
在 JDK8 后,它摒弃了 Segment(锁段)的概念,而是启用了一种全新的方式实现,利用 CAS 算法。同时加入了更多的辅助变量来提高并发度,具体内容还是查看源码吧。
? ConcurrentHashMap 为何读不用加锁?
在 JDK7 以及以前
key
、hash
、next
均为 final
型,只能表头插入、删除结点。HashEntry 类的 value
域被声明为 volatile
型。不允许用 null
作为键和值,当读线程读到某个 HashEntry 的 value
域的值为 null
时,便知道产生了冲突——发生了重排序现象(put 方法设置新 value
对象的字节码指令重排序),需要加锁后重新读入这个 value
值。volatile
变量 count
协调读写线程之间的内存可见性,写操作后修改 count
,读操作先读 count
,根据 happen-before 传递性原则写操作的修改读操作能够看到。在 JDK8 开始
val
和 next
均为 volatile
型。#tabAt(..,)
和 #casTabAt(...)
对应的 Unsafe 操作实现了 volatile
语义。CopyOnWriteArrayList(免锁容器)的好处之一是当多个迭代器同时遍历和修改这个列表时,不会抛出ConcurrentModificationException 异常。在 CopyOnWriteArrayList 中,写入将导致创建整个底层数组的副本,而源数组将保留在原地,使得复制的数组在被修改时,读取操作可以安全地执行。
CopyOnWriteArrayList 透露的思想:
CopyOnWriteArrayList 适用于读操作远远多于写操作的场景。例如,缓存。
关于 CopyOnWriteArrayList 的源码,可以看看 《CopyOnWriteArrayList 实现原理及源码分析》 文章。
阻塞队列(BlockingQueue)是一个支持两个附加操作的队列。这两个附加的操作是:
阻塞队列常用于生产者和消费者的场景:
艿艿:如下的内容,和上面是相对重复的,或者是换一个说法,重新描述。
BlockingQueue 接口,是 Queue 的子接口,它的主要用途并不是作为容器,而是作为线程同步的的工具,因此他具有一个很明显的特性:
阻塞队列使用最经典的场景,就是 Socket 客户端数据的读取和解析:
JDK7 提供了 7 个阻塞队列。分别是:
Java5 之前实现同步存取时,可以使用普通的一个集合,然后在使用线程的协作和线程同步可以实现生产者,消费者模式,主要的技术就是用好 wait、notify、notifyAll、
sychronized
这些关键字。而在 Java5 之后,可以使用阻塞队列来实现,此方式大大简少了代码量,使得多线程编程更加容易,安全方面也有保障。
【最常用】ArrayBlockingQueue :一个由数组结构组成的有界阻塞队列。
此队列按照先进先出(FIFO)的原则对元素进行排序,但是默认情况下不保证线程公平的访问队列,即如果队列满了,那么被阻塞在外面的线程对队列访问的顺序是不能保证线程公平(即先阻塞,先插入)的。
LinkedBlockingQueue :一个由链表结构组成的有界阻塞队列。
此队列按照先出先进的原则对元素进行排序
PriorityBlockingQueue :一个支持优先级排序的无界阻塞队列。
DelayQueue:支持延时获取元素的无界阻塞队列,即可以指定多久才能从队列中获取当前元素。
SynchronousQueue:一个不存储元素的阻塞队列。
每一个 put 必须等待一个 take 操作,否则不能继续添加元素。并且他支持公平访问队列。
LinkedTransferQueue:一个由链表结构组成的无界阻塞队列。
相对于其他阻塞队列,多了 tryTransfer 和 transfer 方法。
- transfer 方法:如果当前有消费者正在等待接收元素(take 或者待时间限制的 poll 方法),transfer 可以把生产者传入的元素立刻传给消费者。如果没有消费者等待接收元素,则将元素放在队列的 tail 节点,并等到该元素被消费者消费了才返回。
- tryTransfer 方法:用来试探生产者传入的元素能否直接传给消费者。如果没有消费者在等待,则返回 false 。和上述方法的区别是该方法无论消费者是否接收,方法立即返回。而 transfer 方法是必须等到消费者消费了才返回。
LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列。
优势在于多线程入队时,减少一半的竞争。
阻塞队列提供哪些重要方法?
方法处理方式 | 抛出异常 | 返回特殊值 | 一直阻塞 | 超时退出 |
---|---|---|---|---|
插入方法 | add(e) | offer(e) | put(e) | offer(e, time, unit) |
移除方法 | remove() | poll() | take() | poll(time, unit) |
检查方法 | element() | peek() | 不可用 | 不可用 |
? ArrayBlockingQueue 与 LinkedBlockingQueue 的区别?
Queue | 阻塞与否 | 是否有界 | 线程安全保障 | 适用场景 | 注意事项 |
---|---|---|---|---|---|
ArrayBlockingQueue | 阻塞 | 有界 | 一把全局锁 | 生产消费模型,平衡两边处理速度 | 用于存储队列元素的存储空间是预先分配的,使用过程中内存开销较小(无须动态申请存储空间) |
LinkedBlockingQueue | 阻塞 | 可配置 | 存取采用 2 把锁 | 生产消费模型,平衡两边处理速度 | 无界的时候注意内存溢出问题,用于存储队列元素的存储空间是在其使用过程中动态分配的,因此它可能会增加 JVM 垃圾回收的负担。 |
在上面,我们看到的 LinkedBlockingQueue、ArrayBlockingQueue、PriorityBlockingQueue、SynchronousQueue 等,都是阻塞队列。
而 ArrayDeque、LinkedBlockingDeque 就是双端队列,类名以 Deque 结尾。
在 Java 多线程应用中,队列的使用率很高,多数生产消费模型的首选数据结构就是队列(先进先出)。
Java 提供的线程安全的 Queue 可以分为
阻塞队列,典型例子是 LinkedBlockingQueue 。
适用阻塞队列的好处:多线程操作共同的队列时不需要额外的同步,另外就是队列会自动平衡负载,即那边(生产与消费两边)处理快了就会被阻塞掉,从而减少两边的处理速度差距。
非阻塞队列,典型例子是 ConcurrentLinkedQueue 。
当许多线程共享访问一个公共集合时,
ConcurrentLinkedQueue
是一个恰当的选择。
具体的选择,如下:
原子操作(Atomic Operation),意为”不可被中断的一个或一系列操作”。
原子操作是指一个不受其他操作影响的操作任务单元。原子操作是在多线程环境下避免数据不一致必须的手段。
int++
并不是一个原子操作,所以当一个线程读取它的值并加 1 时,另外一个线程有可能会读到之前的值,这就会引发错误。java.util.concurrent.atomic
包提供了 int
和 long
类型的原子包装类,它们可以自动的保证对于他们的操作是原子的并且不需要使用同步。java.util.concurrent
这个包里面提供了一组原子类。其基本的特性就是在多线程环境下,当有多个线程同时执行这些类的实例包含的方法时,具有排他性,即当某个线程进入方法,执行其中的指令时,不会被其他线程打断,而别的线程就像自旋锁一样,一直等到该方法执行完成,才由 JVM 从等待队列中选择一个另一个线程进入,这只是一种逻辑上的理解。
boolean
来反映中间有没有变过),AtomicStampedReference(通过引入一个 int
来累加来反映中间有没有变过)。1)ABA 问题
比如说一个线程 one 从内存位置 V 中取出 A ,这时候另一个线程 two 也从内存中取出 A ,并且 two 进行了一些操作变成了 B ,然后 two 又将 V 位置的数据变成 A ,这时候线程 one 进行 CAS 操作发现内存中仍然是 A ,然后 one 操作成功。尽管线程 one 的 CAS 操作成功,但可能存在潜藏的问题。
从 Java5 开始 JDK 的 atomic
包里提供了一个类 AtomicStampedReference 来解决 ABA 问题。
2)循环时间长开销大
对于资源竞争严重(线程冲突严重)的情况,CAS 自旋的概率会比较大,从而浪费更多的 CPU 资源,效率低于 synchronized
。
3)只能保证一个共享变量的原子操作
当对一个共享变量执行操作时,我们可以使用循环 CAS 的方式来保证原子操作,但是对多个共享变量操作时,循环 CAS 就无法保证操作的原子性,这个时候就可以用锁。
Semaphore ,是一种新的同步类,它是一个计数信号。从概念上讲,从概念上讲,信号量维护了一个许可集合。
#acquire()
方法,然后再获取该许可。#release()
方法,添加一个许可,从而可能释放一个正在阻塞的获取者。信号量常常用于多线程的代码中,比如数据库连接池。
CountDownLatch ,字面意思是减小计数(CountDown)的门闩(Latch)。它要做的事情是,等待指定数量的计数被减少,意味着门闩被打开,然后进行执行。
CountDownLatch 默认的构造方法是 CountDownLatch(int count)
,其参数表示需要减少的计数,主线程调用 #await()
方法告诉 CountDownLatch 阻塞等待指定数量的计数被减少,然后其它线程调用 CountDownLatch 的 #countDown()
方法,减小计数(不会阻塞)。等待计数被减少到零,主线程结束阻塞等待,继续往下执行。
CyclicBarrier ,字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。
CyclicBarrier 默认的构造方法是 CyclicBarrier(int parties)
,其参数表示屏障拦截的线程数量,每个线程调用 #await()
方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞,直到 parties
个线程到达,结束阻塞。
CyclicBarrier 可以重复使用,而 CountdownLatch 不能重复使用。
#await()
方法都会阻塞,直到这个计数器的计数值被其他的线程减为 0 为止。所以在当前计数到达零之前,await 方法会一直受阻塞。之后,会释放所有等待的线程,await 的所有后续调用都将立即返回。这种现象只出现一次——计数无法被重置。如果需要重置计数,请考虑使用 CyclicBarrier 。#await()
方法,其他的任务执行完自己的任务后调用同一个 CountDownLatch 对象上的 #countDown()
方法,这个调用 #await()
方法的任务将一直阻塞等待,直到这个 CountDownLatch 对象的计数值减到 0 为止。整理表格如下:
CountDownLatch | CyclicBarrier |
---|---|
减计数方式 | 加计数方式 |
计算为 0 时释放所有等待的线程 | 计数达到指定值时释放所有等待线程 |
计数为 0 时,无法重置 | 计数达到指定值时,计数置为 0 重新开始 |
调用 #countDown() 方法计数减一,调用 #await() 方法只进行阻塞,对计数没任何影响 |
调用 #await() 方法计数加 1 ,若加 1 后的值不等于构造方法的值,则线程阻塞 |
不可重复利用 | 可重复利用 |
Executor 框架,是一个根据一组执行策略调用,调度,执行和控制的异步任务的框架。
无限制的创建线程,会引起应用程序内存溢出。所以创建一个线程池是个更好的的解决方案,因为可以限制线程的数量并且可以回收再利用这些线程。利用 Executor 框架,可以非常方便的创建一个线程池。
? 为什么使用 Executor 框架?
new Thread()
比较消耗性能,创建一个线程是比较耗时、耗资源的。new Thread()
创建的线程缺乏管理,被称为野线程,而且可以无限制的创建,线程之间的相互竞争会导致过多占用系统资源而导致系统瘫痪,还有线程之间的频繁交替也会消耗很多系统资源。new Thread()
启动的线程不利于扩展,比如定时执行、定期执行、定时定期执行、线程中断等都不便实现。? 在 Java 中 Executor 和 Executors 的区别?
#get()
方法,获取计算的结果。Java 类库提供一个灵活的线程池以及一些有用的默认配置,我们可以通过Executors 的静态方法来创建线程池。
Executors 创建的线程池,分成普通任务线程池,和定时任务线程池。
#newFixedThreadPool(int nThreads)
方法,创建一个固定长度的线程池。每当提交一个任务就创建一个线程,直到达到线程池的最大数量,这时线程规模将不再变化。当线程发生未预期的错误而结束时,线程池会补充一个新的线程。#newCachedThreadPool()
方法,创建一个可缓存的线程池。如果线程池的规模超过了处理需求,将自动回收空闲线程。当需求增加时,则可以自动添加新线程。线程池的规模不存在任何限制。#newSingleThreadExecutor()
方法,创建一个单线程的线程池。它创建单个工作线程来执行任务,如果这个线程异常结束,会创建一个新的来替代它。它的特点是,能确保依照任务在队列中的顺序来串行执行。#newScheduledThreadPool(int corePoolSize)
方法,创建了一个固定长度的线程池,而且以延迟或定时的方式来执行任务,类似 Timer 。#newSingleThreadExecutor()
方法,创建了一个固定长度为 1 的线程池,而且以延迟或定时的方式来执行任务,类似 Timer 。? 如何使用 ThreadPoolExecutor 创建线程池?
Executors 提供了创建线程池的常用模板,实际场景下,我们可能需要自动以更灵活的线程池,此时就需要使用 ThreadPoolExecutor 类。
// ThreadPoolExecutor.java
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;
}
corePoolSize
参数,核心线程数大小,当线程数 < corePoolSize ,会创建线程执行任务。maximumPoolSize
参数,最大线程数, 当线程数 >= corePoolSize 的时候,会把任务放入 workQueue
队列中。keepAliveTime
参数,保持存活时间,当线程数大于 corePoolSize
的空闲线程能保持的最大时间。unit
参数,时间单位。workQueue
参数,保存任务的阻塞队列。handler
参数,超过阻塞队列的大小时,使用的拒绝策略。threadFactory
参数,创建线程的工厂。? ThreadPoolExecutor 有哪些拒绝策略?
ThreadPoolExecutor 默认有四个拒绝策略:
ThreadPoolExecutor.AbortPolicy()
,直接抛出异常 RejectedExecutionException 。ThreadPoolExecutor.CallerRunsPolicy()
,直接调用 run 方法并且阻塞执行。ThreadPoolExecutor.DiscardPolicy()
,直接丢弃后来的任务。ThreadPoolExecutor.DiscardOldestPolicy()
,丢弃在队列中队首的任务。如果我们有需要,可以自己实现 RejectedExecutionHandler 接口,实现自定义的拒绝逻辑。当然,绝大多数是不需要的。
ThreadPoolExecutor 提供了两个方法,用于线程池的关闭,分别是:
#shutdown()
方法,不会立即终止线程池,而是要等所有任务缓存队列中的任务都执行完后才终止,但再也不会接受新的任务。#shutdownNow()
方法,立即终止线程池,并尝试打断正在执行的任务,并且清空任务缓存队列,返回尚未执行的任务。实际场景下,一般会结合这两个方法,一起实现线程池的优雅关闭。示例代码如下:
void shutdownAndAwaitTermination(ExecutorService pool) {
pool.shutdown(); // Disable new tasks from being submitted
try {
// Wait a while for existing tasks to terminate
if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
pool.shutdownNow(); // Cancel currently executing tasks
// Wait a while for tasks to respond to being cancelled
if (!pool.awaitTermination(60, TimeUnit.SECONDS))
System.err.println("Pool did not terminate");
}
}
} catch (InterruptedException ie) {
// (Re-)Cancel if current thread also interrupted
pool.shutdownNow();
// Preserve interrupt status
Thread.currentThread().interrupt();
}
}
一般说来,大家认为线程池的大小经验值应该这样设置:(其中 N 为CPU的个数)
如果是 CPU 密集型应用,则线程池大小设置为 N+1
因为 CPU 密集型任务使得 CPU 使用率很高,若开过多的线程数,只能增加上下文切换的次数,因此会带来额外的开销。
如果是 IO 密集型应用,则线程池大小设置为 2N+1
IO密 集型任务 CPU 使用率并不高,因此可以让 CPU 在等待 IO 的时候去处理别的任务,充分利用 CPU 时间。
如果是混合型应用,那么分别创建线程池
可以将任务分成 IO 密集型和 CPU 密集型任务,然后分别用不同的线程池去处理。 只要分完之后两个任务的执行时间相差不大,那么就会比串行执行来的高效。
因为如果划分之后两个任务执行时间相差甚远,那么先执行完的任务就要等后执行完的任务,最终的时间仍然取决于后执行完的任务,而且还要加上任务拆分与合并的开销,得不偿失。
如果一台服务器上只部署这一个应用并且只有这一个线程池,那么这种估算或许合理,具体还需自行测试验证。
但是,IO 优化中,这样的估算公式可能更适合:最佳线程数目 = ((线程等待时间 + 线程 CPU 时间)/ 线程 CPU 时间 )* CPU 数目 因为很显然,线程等待时间所占比例越高,需要越多线程。线程CPU时间所占比例越高,需要越少线程。
下面举个例子:比如平均每个线程 CPU 运行时间为 0.5s ,而线程等待时间(非 CPU 运行时间,比如 IO)为 1.5s ,CPU 核心数为 8 。 那么根据上面这个公式估算得到:((0.5 + 1.5) / 0.5) * 8 = 32
。这个公式进一步转化为:最佳线程数目 = (线程等待时间与线程 CPU 时间之比 + 1)* CPU数目。
线程池容量的动态调整?
ThreadPoolExecutor 提供了动态调整线程池容量大小的方法:
当上述参数从小变大时,ThreadPoolExecutor 进行线程赋值,还可能立即创建新的线程来执行任务。
1)Callable
Callable 接口,类似于 Runnable ,从名字就可以看出来了,但是Runnable 不会返回结果,并且无法抛出返回结果的异常,而 Callable 功能更强大一些,被线程执行后,可以返回值,这个返回值可以被 Future 拿到,也就是说,Future 可以拿到异步执行任务的返回值。
简单来说,可以认为是带有回调的 Runnable 。
2)Future
Future 接口,表示异步任务,是还没有完成的任务给出的未来结果。所以说 Callable 用于产生结果,Future 用于获取结果。
3)FutureTask
在 Java 并发程序中,FutureTask 表示一个可以取消的异步运算。
刚创建时,里面没有线程调用 execute() 方法,添加任务时:
corePoolSize
,继续创建线程运行这个任务否则,如果正在运行的线程数量大于或等于 corePoolSize
,将任务加入到阻塞队列中。否则,如果队列已满,同时正在运行的线程数量小于核心参数 maximumPoolSize
,继续创建线程运行这个任务。否则,如果队列已满,同时正在运行的线程数量大于或等于 maximumPoolSize
,根据设置的拒绝策略处理。corePoolSize
。? 线程池中 submit 和 execute 方法有什么区别?
两个方法都可以向线程池提交任务。
#execute(...)
方法,返回类型是 void
,它定义在 Executor 接口中。#submit(...)
方法,可以返回持有计算结果的 Future 对象,它定义在 ExecutorService 接口中,它扩展了 Executor 接口,其它线程池类像 ThreadPoolExecutor 和 ScheduledThreadPoolExecutor 都有这些方法。如果你提交任务时,线程池队列已满,这时会发生什么?
艿艿:重点在于线程池的队列是有界还是无界的。
1、CountDownLatch:允许一个或者多个线程等待前面的一个或多个线程完成,构造一个 CountDownLatch 时指定需要 countDown 的点的数量,每完成一点就 countDown 一下。当所有点都完成,CountDownLatch 的 #await()
就解除阻塞。
2、CyclicBarrier:可循环使用的 Barrier ,它的作用是让一组线程到达一个 Barrier 后阻塞,直到所有线程都到达 Barrier 后才能继续执行。
CountDownLatch 的计数值只能使用一次,CyclicBarrier 可以通过使用 reset 重置,还可以指定到达栅栏后优先执行的任务。