文章目录
一、前言
本篇是阅读《Java 并发编程实战》的零碎笔记,整体非常凌乱,并不适合阅读,仅做个人备忘。
二、基础构建模块
第五章内容
1. 双端队列和工作密取
Java 6新增了两种容器类型 Deque 和 BlockingDeque,分别对应 Queue 和 BlockingQueue 进行了扩展。Deque 时一个双端队列,在队头和队尾都可以高效插入和移除,实现包括 ArrayDeque 和 LinkedBlockingDeque 等。
相较于阻塞队列适用于生产者消费者模式,双端队列更适用于工作密取(Work Stealing)的设计中。
在生产者消费者模式中,所有的消费者都共享同一个工作队列,而在工作密取的设计中,每个消费者都有各自的双端队列。如果一个消费者完成了自己的双端队列中的全部工作,那么他就可以从其他消费者双端队列尾部秘密地获取工作。
密取工作模式相较于传统的生产者消费者模式具备更高的可伸缩性,这是因为工作者线程不会在单个共享的任务队列中发生竞争。在大多数时候,他们只需要访问自己的双端队列,从而极大减少了竞争。当工作者线程需要访问一个队列时,他会从队尾获取工作,从而进一步降低了队列上的竞争程度。
2. 阻塞方法与中断方法
线程可能会阻塞或暂停执行,当线程被阻塞时,它通常被挂起并处于某种阻塞状态(BLOCKED、WAITING、TIMED_WAITING)。阻塞操作与执行时间很长的普通操作区别在于:被阻塞的线程必须等待某个不受它控制的事件发生后才能继续执行,如等待IO操作完成、等待某个锁变成可用等。当某个外部事件发生时,线程被置回为 RUNNABLE 状态,并可以再次被调度执行。
BlockingQueue 的 put 和 take 操作会抛出受检查异常 InterruptedException ,这与类库中的其他一些方法的做法相同,如 Thread.sleep。当某方法抛出 InterruptedException 时,表示该方法时一个阻塞方法,如果这个方法被中断,那么它将努力结束阻塞状态。
Thread 提供了 interrupted 方法,用于中断线程或者查询线程是否已经被中断。每个线程都有个一个 boolean 类型的属性表示线程的中断状态,当中断线程时将设置这个状态。
中断是一种协作机制,一个线程不能强制其他线程停止正在执行的操作而去执行其他的操作。当线程A中断线程B时,A仅仅是要求B在执行到某个可以暂停的地方停止正在执行的操作,不过前提是线程 B 愿意停止下来。
Thread 提供了三个与中断有关的方法:
- interrupt:设置线程的中断状态
- isInterrupt:返回线程是否中断状态
- interrupted:返回线程的上次的中断状态,并清除中断状态。这是唯一可以清除中断的方法。
对线程中断的正确理解是:他不会真正中断一个正在运行的线程,而只是会发出中断请求,然后由线程在下一个合适的时刻中断自己(这些时间也被称为取消点)。有些方法(如wait、sleep、join 等)会严格处理这种请求,当他们收到中断请求或者在开始执行时发现某个已经被设置好的中断状态时将会抛出一个异常。
以下面的例子为例:
public static void main(String[] args) {
// 获取线程当前中断状态 : false
final boolean interrupted = Thread.currentThread().isInterrupted();
System.out.println("interrupted = " + interrupted);
// 中断线程
Thread.currentThread().interrupt();
// 获取线程当前中断状态 : true
final boolean interrupted1 = Thread.currentThread().isInterrupted();
System.out.println("interrupted1 = " + interrupted1);
// 获取线程当前中断状态,如果是中断状态,则清除中断状态
Thread.currentThread().interrupted();
// 获取当前线程状态 : false,因为中断状态被 interrupted 方法重置
final boolean interrupted2 = Thread.currentThread().isInterrupted();
System.out.println("interrupted2 = " + interrupted2);
}
当在代码中调用了一个将抛出 InterruptedException 异常的方法时,你自己的方法也就成为了一个阻塞方法,并且必须要处理对中断的响应。对于库代码来说一般有两种选择:
-
传递 InterruptedException :将 InterruptedException 异常传递给方法的调用者,可以通过 不捕获该异常 或 捕获该异常后再次抛出异常的方式。
-
恢复中断 :有时候不能抛出 InterruptedException 异常,这种情况必须捕获掉该异常,并通过调用当前线程上的 interrupted 方法恢复中断状态,这样在调用栈中更高层代码将看到引发了一个中断。 如下,由于在 Runnable 方法中不能抛出 InterruptedException 异常,可以将线程状态置为 中断,交由调用上层(Main 方法)判断是否需要终止后续逻辑执行。
// 这个例子没有什么实际业务意义 public static void main(String[] args) throws InterruptedException { BlockingQueue<String> blockingQueue = new LinkedBlockingQueue<>(); final InterruptedDemoMain main = new InterruptedDemoMain(); final Runnable run = main.run(blockingQueue); run.run(); if (Thread.currentThread().isInterrupted()) { System.out.println("线程已经被中断"); return; } // 继续后续工作 System.out.println("执行后续业务逻辑"); final String take = blockingQueue.take(); System.out.println("take = " + take); } public Runnable run(BlockingQueue<String> blockingQueue) { return new Runnable() { @Override public void run() { try { blockingQueue.put("123"); } catch (InterruptedException e) { // 将线程值为中断状态 Thread.currentThread().interrupt(); } } }; }
3. 闭锁(CountDownLatch)、信号量(Semaphore)以及栅栏(CyclicBarrier)
栅栏 类似于 闭锁,区别在于:所有线程必须同时到达栅栏位置才能继续执行,闭锁用于等待事件,而栅栏用于等待其他线程。
CyclicBarrier 可以使一定数量的参与方反复地在栅栏位置汇集,在并行迭代算法中非常有用:这种算法通常将一个问题拆分为一系列相互独立的子问题。当线程达到栅栏位置时将调用 await 方法,这个方法将阻塞知道所有线程都到达栅栏位置,如果所有线程都到达了栅栏位置,那么栅栏将打开,此时所有线程被释放,而栅栏将被重置以便下次使用。如果成功通过栅栏,那么await 将为每个检查返回一个唯一的到达索引号,可以利用这些索引来“选举”产生一个领导线程,并在下一次迭代中由该领导线程执行一些特殊的工作。(如选举出响应时间最快的服务等)
public static void main(String[] args) throws InterruptedException, ExecutionException {
CyclicBarrier cyclicBarrier = new CyclicBarrier(3);
final FutureTask<Integer> futureTask1 =
new FutureTask<>(cyclicBarrier::await);
final FutureTask<Integer> futureTask2 =
new FutureTask<>(cyclicBarrier::await);
final FutureTask<Integer> futureTask3 =
new FutureTask<>(cyclicBarrier::await);
new Thread(futureTask1).start();
new Thread(futureTask2).start();
new Thread(futureTask3).start();
// 输出如下:
// futureTask1 = 0
// futureTask2 = 2
// futureTask3 = 1
System.out.println("futureTask1 = " + futureTask1.get());
System.out.println("futureTask2 = " + futureTask2.get());
System.out.println("futureTask3 = " + futureTask3.get());
}
4. 构建高效且可伸缩的结果缓存
这里记录的是一个思路,因为给了我些许适用 FutureTask 的启发:对于一些方法的返回结果,可以暴露返回FutureTask类型,这样在实际使用数据的时候才会调用 FutureTask#get 方法,实现一个类似懒加载的方式
如下一个示例:假设 compute.compute(result) 方法执行非常耗时,那么对于多次同样的计算调用就需要缓存,就出现了如下代码,但是这个代码在多线程调用情况下无法满足上述情况。
public class FutureTaskDemoMain {
/**
* 计算缓存结果
*/
private Map<String, String> cache = Maps.newHashMap();
/**
* 计算器
*/
private Compute compute;
/**
* 计算
*
* @param arg
* @return
*/
public String compute(final String arg) {
String result = cache.get(arg);
if (result == null) {
result = compute.compute(result);
cache.put(arg, result);
}
return result;
}
}
因此这里做了改造如下:
- cache 换成 ConcurrentMap 支持多线程
- 通过 FutureTask 来缓存结果。
/**
* 计算缓存结果
*/
private Map<String, FutureTask<String>> cache = Maps.newConcurrentMap();
/**
* 计算
*
* @param arg
* @return
*/
@SneakyThrows
public String compute(final String arg) {
return cache.computeIfAbsent(arg, new Function<String, FutureTask<String>>() {
@Override
public FutureTask<String> apply(String s) {
return new FutureTask<>(new Callable<String>() {
@Override
public String call() throws Exception {
return compute.compute(arg);
}
});
}
}).get();
}
三、任务执行
第六章内容
1. ExecutorService
JVM 只有在所有(非守护)线程全部终止后才会退出,因此,如果无法正确的关闭线程池,那么JVM将无法结束。 Executor 提供了扩展接口 ExecutorService,ExecutorService 的生命周期有3中状态:运行、关闭和已终止。ExecutorService 在创建时处于运行状态,通过 shutdown 或 shutdownNow 方法可以关闭线程池。等所有任务完成后, ExecutorService 转入终止状态,可以调用 awaitTermination 方法等待线程池达到终止状态,或者通过 isTerminated 来轮询判断是否终止。
ExecutorService 中提供了生命周期管理的方法,如下:
public interface ExecutorService extends Executor {
// 优雅停机
void shutdown();
// 暴力停机,不推荐
List<Runnable> shutdownNow();
// 是否调用了停机
boolean isShutdown();
// 是否终止
boolean isTerminated();
// 等待指定时间,返回是否终止的标识。
boolean awaitTermination(long timeout, TimeUnit unit)
throws InterruptedException;
... 其他用户任务提交的方法
}
这里看下面两个方法:
-
shutdown 方法将执行平缓的关闭过程:不再接受新的任务,同时等待已经提交的任务执行完成(包括那些还未开始执行的任务)。
public void shutdown() { final ReentrantLock mainLock = this.mainLock; mainLock.lock(); try { checkShutdownAccess(); advanceRunState(SHUTDOWN); // 中断存活且空闲worker interruptIdleWorkers(); onShutdown(); // hook for ScheduledThreadPoolExecutor } finally { mainLock.unlock(); } tryTerminate(); } private void interruptIdleWorkers() { interruptIdleWorkers(false); } private void interruptIdleWorkers(boolean onlyOne) { final ReentrantLock mainLock = this.mainLock; mainLock.lock(); try { for (Worker w : workers) { Thread t = w.thread; // 只有空闲的worker调用tryLock会返回true, // 因为线程池在添加任务的时候如果线程在执行任务会锁住。所以只有空闲的worker 才会获取到锁 if (!t.isInterrupted() && w.tryLock()) { try { t.interrupt(); } catch (SecurityException ignore) { } finally { w.unlock(); } } if (onlyOne) break; } } finally { mainLock.unlock(); } }
-
shutdownNow 方法将执行粗暴的关闭过程:他将尝试取消所有运行中的任务,并且不再启动队列中尚未开始执行的任务。
public List<Runnable> shutdownNow() { List<Runnable> tasks; final ReentrantLock mainLock = this.mainLock; mainLock.lock(); try { checkShutdownAccess(); advanceRunState(STOP); // 与 shutdown 不同的是,这里会中断所有线程 interruptWorkers(); tasks = drainQueue(); } finally { mainLock.unlock(); } tryTerminate(); return tasks; } private void interruptWorkers() { final ReentrantLock mainLock = this.mainLock; mainLock.lock(); try { for (Worker w : workers) w.interruptIfStarted(); } finally { mainLock.unlock(); } }
本段 参考 https://blog.youkuaiyun.com/feiying101/article/details/138291778
即使我们使用了shutdown()或shutdownNow()方法,线程池中的任务有时候也无法被正确中断。这往往发生在以下几种情况中:
- 如果任务内部没有处理中断信号。例如,调用了阻塞操作(如Thread.sleep)且没有正确处理InterruptedException。
- 任务执行了非阻塞的长运算过程,无法检查中断状态。
- 任务持有了某些资源锁,没有在适当的时候释放,造成其他任务或者线程池的关闭流程卡住。
如下:因为 shutdown 方法会等待已经提交的任务执行完成,因此如果任务是 while 死循环的状态,则不会被关闭,从而导致整个线程池无法关闭。如下(如果需要关闭 需要通过一个单独的flag 字段判断,当线程池shutdown 后,flag 字段变化,while 判断 flag 变化后停止):
executorService.execute(new Runnable() {
@Override
public void run() {
while (true) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
}
log.info("线程终止状态 {}", Thread.currentThread().isInterrupted());
}
}
});
// 调用shutdown 是无法关闭调线程池,因为while 循环不会终止。
// 如果调用 shutdownNow 方法,则会强行将任务线程的 interrupted 状态改为 true,在上述方法中 Thread.sleep 就会抛出异常,告知终止状态,但上述代码中并没有处理这种情况
executorService.shutdown();
如何处理资源清理与状态恢复 要确保线程池能够优雅退出,所有任务都应该遵循以下准则:
- 响应中断:在任务中检查当前线程的中断状态,适时退出长时间运行的循环。
- 清理资源:即使在捕获到中断异常时,也要确保所有在任务中分配的资源得到释放。
- 恢复状态:若任务被中断,确保能够将涉及的数据或状态恢复到一个安全的状态。
综上,线程池优雅停机的一个示例如下:
public class SafeTask implements Runnable {
private volatile boolean running = true;
public void run() {
try {
while (running && !Thread.currentThread().isInterrupted()) {
// ...执行任务
// 模拟长时间运行
Thread.sleep(1000);
}
} catch (InterruptedException ie) {
Thread.currentThread().interrupt(); // 重新设置中断状态
} finally {
// 清理资源
closeResources();
}
}
public void closeResources() {
// 释放或关闭分配的资源
// ...
}
public void stop() {
running = false; // 允许任务退出
}
}
2. Timer
Timer 类负责管理延迟任务,但是其存在恶性问题:
- Timer 处理所有定时任务时都只会创建一个线程,因此会破坏各个任务执行的精确性。
- TimerTask 在执行过程中抛出了一个异常,Timer 并不会去尝试捕获他,因此执行线程将会因为异常而终止,这就导致剩余未执行的定时任务奖无法得到执行。
可以使用 ScheduledThreadPoolExecutor 来代替 Timer 。(Timer 支持绝对时间调度机制,因此任务的执行对系统时钟变化很敏感,而 ScheduledThreadPoolExecutor 只支持基于相对时间的调度)。
**如果要构建自己的调度服务,可以使用 DelayQueue 来完成,他实现了BlockingQueue 并为 ScheduledThreadPoolExecutorService 提供调度功能。**DelayQueue 管理着一组 Delayed 对象,每个对象都有一个相应的延迟时间,在 DelayQueue 中,只有某个元素逾期后才能从 DelayQueue 中执行 take 操作。从 DelayQueue 中返回的对象将根据他们的延迟时间进行排序。
3. CompletionService
**CompletionService 将 Executor 和 BlockingQueue 功能融合在一起。可以将任务提交给他,然后通过take 或 poll 方法获取已经完成的结果。在需要使用 Executor 和 BlockingQueue 的场景下可以使用。**简单使用如下:
CompletionService<String> completionService =
new ExecutorCompletionService<>(Executors.newFixedThreadPool(10));
completionService.submit(() -> {
// TODO : 模拟业务逻辑处理
return "";
});
while (true) {
// 获取已经完成的结果,如果没有则阻塞
final Future<String> take = completionService.take();
final String o = take.get();
}
在使用 Future 时,通过get 方法获取结果时,如果超时(设置了超时时间)可以通过 Future.cancel 设置任务是可取消的,如下:
while (true) {
// 获取已经完成的结果,如果没有则阻塞
final Future<String> take = completionService.take();
try {
final String o = take.get(10, TimeUnit.SECONDS);
} catch (TimeoutException e) {
// true 表示当前任务可取消
take.cancel(true);
throw new RuntimeException(e);
}
}
四、取消和关闭
第七章内容
1. 任务取消
如果外部代码能在某个操作正常完成之前将其置入 完成 状态,那么这个操作可以成为可取消的。取消某个操作的原因有很多:
用户请求取消、有时间限制的操作等。
在Java 中没有一种安全的抢占式方法来停止线程,因此也就没有安全的抢占式方法来停止任务,只有一些协作式的机制,使请求取消的任务和代码都遵循一种协商好的协议。
其中一种协作机制就是能设置某个 已请求取消 的标志,而任务将定查看该标志,如果设置了这个标志则任务提交结束。在上述的 Future.cancel 就是使用了该机制。
- Future#cancel 方法 代表是否可以在任务运行中将其终止,如果为 true 并且任务当前正在某个线程中运行,则这线程能被中断;如果为false,则意味着 如果任务还没有启动,就不要运行它,这种方式运用于那些不处理中断的任务中。
- 如果使用 线程池来执行 Future 时,不要通过终止线程池的方式来终止任务,而是通过 Future#cancel 的方式来终止任务,因为线程池并不知道中断请求到达时正在运行什么任务。
在某些情况下即使设置了任务的取消状态,程序也可能并不会检测中断状态导致任务无法取消。线程的中断机制也是一种协作机制,具体方法在上面已经介绍。
对线程中断的正确理解是:他不会真正中断一个正在运行的线程,而只是会发出中断请求,然后由线程在下一个合适的时刻中断自己(这些时间也被称为取消点)。有些方法(如wait、sleep、join 等)会严格处理这种请求,当他们收到中断请求或者在开始执行时发现某个已经被设置好的中断状态时将会抛出一个异常。(因此某些场景下似乎可以考虑通过这个中断机制来完成任务的取消)
不要在外部线程上安排线程中断(如下): 因为 cancel 方法可以在任意线程调用,但是他无法知道这个调用线程的中断策略。
public void cancel(){
final Thread thread = Thread.currentThread();
Executors.newSingleThreadExecutor().execute(thread::interrupt);
}
2. “毒丸”对象
生产者消费者的任务取消方式之一是“毒丸”对象,“毒丸”对象 指的是在生产者消费者模型中的一个特殊的对象,当得到这个对象是,立即停止。在 FIFO 队列中,“毒丸”对象将确保消费者在关闭之前首先完成队列中的所有工作,在提交 “毒丸”对象 之前提交的所有工作都会被处理,在“毒丸”对象之后提交的所有工作则不会再被处理。
只有生产者和消费者数量都已知的情况下,才能使用 “毒丸”对象,因为要确保所有的消费者都接收到了“毒丸”对象,并且这种方案在生产者和消费者数量较大时将难以使用。
3. JVM 关闭
JVM 的关闭可以是正常或强行关闭的,正常关闭的的触发方式有多种,如:当最后一个非守护线程结束时、调用 System.exit 时等。
在正常关闭的JVM过程中,JVM 首先调用所有已经注册的关闭钩子(Shutdown Hook),关闭钩子是指通过 Runtime.getRuntime().addShutdownHook 注册但尚未开始的守护线程。JVM 并不能保证钩子的调用顺序,因此在关闭应用程序线程时,如果有守护线程和非守护线程都在运行时,那么这些线程接下来将与关闭进程并发执行。当所有的关闭钩子都执行结束时,如果 runFinalizersOnExit 仍为 true,则 JVM 将运行终结器,然后再停止。JVM并不会停止或中断在关闭时仍在运行的应用程序线程。当 JVM 最终结束时,这些线程将被强行结束。如果关闭钩子或终结器没有执行完成,那么正常关闭进程 “挂起” 并且 JVM 必须被强行关闭。当被强行关闭时,只是关闭JVM,而不会关闭钩子。
钩子服务需要注意:
- 钩子服务是并行的,因此钩子代码需要保证是线程安全的,并且需要注意避免发生死锁。
- 钩子服务不应该依赖于那些可能被程序或其他钩子关闭的服务。
- 钩子服务必须尽快退出,因为它们会延迟JVM 的结束时间。
4. 守护线程
创建一个线程执行一些辅助工作,但又不希望这个线程阻碍 JVM 关闭,就可以使用守护线程。
在 JVM 启动时创建的线程除了主线程之外全是守护线程(如垃圾回收器线程)。当创建一个新线程时,新线程将继承创建他的线程的守护状态。
普通线程和守护线程的差异仅在于当线程退出时发生的操作:但线程退出时,JVM 会检查其他正在运行的线程,如果这些线程都是守护线程,那么 JVM 会正常退出操作,当 JVM 停止时,所有仍然存在的守护线程都将被抛弃(既不会执行 finally 代码块,也不会执行回卷栈),而 JVM只是直接退出。
应该尽可能减少使用 守护线程,因为很少有线程能在不被清理的情况下安全抛弃。守护线程最好执行内部任务,如周期性的从内存的缓存中移除逾期数据。
5. 终结器
当不再需求内存资源时,可以通过垃圾回收器进行回收,但是对于一些其他资源,如文件句柄或套接字句柄则必须显示地交还给操作系统,为了实现这个功能,垃圾回收器对那些定义了 finalize 方法的对象会进行特殊处理:在回收器释放他们后,调用他们的 finalize 方法,从而保证一些持久化的资源被释放。
由于终结器可以在某个由 JVM 管理的线程中运行,因此终结器访问的任何状态都可能被多个线程访问,所以其访问操作必须进行同步。终结器并不能保证他们将在何时运行甚至是否会运行,并且复杂的终结器通常还会在对象上产生巨大的性能开销。
综上尽量避免编写或使用包含终结器的类。
五、线程池的使用
第八章内容
1. 设置线程池的大小
除了传统需要考虑的 CPU 密集型 (CPU 核数 + 1)或 IO密集型(CPU 核数 * 2 -1)的因素之外,还需要考虑:任务是否需要 JDBC 连接这种稀缺资源的因素。
IO密集型 其实需要综合判断 线程数,不能直接套一个 CPU 核数 * 2 -1
2. 线程池的扩展
- 自定义线程工厂 ThreadFactory ,可以在其中定义线程的相关信息,如名称、UncaughtExceptionHandler (异常处理器)。
- ThreadPoolExecutor 提供了 beforeExecute、afterExecute、terminated 方法可以用来扩展线程池的操作,如添加日志、计时、监视或统计信息收集等功能。
六、避免活跃性危险
第十章内容
1. 死锁
JVM 对死锁的处理不如数据库,当一组 Java 线程死锁时,在没有外力干扰的情况下,死锁的线程将永远不能再使用。根据死锁线程的工作不同,可能会造成应用程序终止、某个特定子系统终止,或性能降低等。
出现线程死锁的原因是:两个线程试图以不同的顺序来获得相同的锁。
在尝试控制锁的获取顺序上时,可以尝试使用根据锁的 hashCode 来决定锁的获取顺序,如下(提供思路,使用场景待定):
final int locka = System.identityHashCode("lock-a");
final int lockb = System.identityHashCode("lock-b");
if (locka > lockb) {
synchronized ("locka") {
synchronized ("lockb") {
// TODO :
}
}
} else if (locka < lockb) {
synchronized ("lockb") {
synchronized ("locka") {
// TODO :
}
}
} else {
// 极低概率可能出现两个锁的 hashCode 相同的情况,此时可以引入加时锁
// 这种概率很小,所以影响不大
synchronized (LOCK) {
synchronized ("locka") {
synchronized ("lockb") {
// TODO :
}
}
}
}
如果在持有锁的情况下调用外部方法,则会出现活跃性问题:在这个外部方法中可能会获取其他锁(这可能会造成死锁),或者阻塞时间过长,导致其他线程无法及时获得当前被持有的锁。
还存在一种资源死锁:如两个线程 A、B都需要连接两个数据库 D1、D2。当资源池为空时会阻塞线程,因此就可能出现在连接池为空的时候, A 持有 D1连接等待 D2 连接, B 持有D2 连接等待D1连接出现死锁。
2. 其他活跃性危险
- 饥饿:由于线程优先级带来的线程饥饿,即一些低优先级的线程可能得不到执行(虽然Java 中线程的优先级并不能保证线程严格按照优先级执行)。此时可以通过 Thread.sleep(0) 或 Thread.yield 方法来释放CPU以保证其他线程可以得到执行。
- 活锁:该问题不会阻塞线程,但是也不能继续执行,因为线程将不断重复执行相同的操作,并且总会失败。比如两条机器同时调用一个接口时失败,然后都约定1s后重试然后再次失败,这样服务便会一直失败下去。要解决这种情况则需要根据业务来引入随机性。
七、性能与可伸缩性
第十一章内容
1. 锁分解以及锁分段
锁分解 :如果一个锁需要保护多个相互独立的状态变量,那么可以将这个锁分解为多个锁,并且每个锁只保护一个变量,从而提高可伸缩性,最终降低每个锁被请求的频率。
锁分段:类似于 ConcurrentHashMap 的每个桶都有一个单独的锁,即将整个 HashMap 的锁分为段锁。锁分段的劣势在于,与采用单个实现独占访问相比,获取多个锁来实现独占访问将更加困难且开销更高。(如 ConcurrentHashMap 需要扩展映射范围,以及重新计算键值的散列值要分布到更大的桶集合中时,会需要获取分段所有集合中的锁。)
八、 并发程序的测试
第十二章内容
当某个类第一次被加载时,JVM 会通过解释字节码的方式来执行它,在某个时刻如果一个方法运行的次数足够多,那么动态编译器就会将他编译为机器代码,当编译完成后,代码的执行方式将从解释执行变成直接执行。
九、显示锁
第十三章内容
1. Lock 和 ReentrantLock
Lock 提供了一种无条件的、可轮询的、定时的以及可中断的锁获取操作,所有加锁和解锁的方式都是显式的。
在大多数情况下,内置锁(synchronized)都可以很好的工作,但是存在一些功能局限,如无法中断一个正在等待获取锁的线程、无法在请求获取一个锁时无限等待下去。内置锁必须要在获取该锁的代码块中释放,这简化了比啊那么工作,但无法实现非阻塞结构的加锁规则。
1.1 轮询锁和定时锁
轮询锁和定时锁的获取模式是由 tryLock 来实现的,与无条件的锁获取模式相比,它具有更完善的错误恢复机制。在内置锁中死锁是一个非常严重的问题,恢复程序的唯一方法是重启程序。而防止死锁的唯一方法就是在构造程序时避免出现不一致的锁顺序,可定时与可轮询的锁提供了另一种选择:避免死锁的发生。
1.2 可中断的锁
可中断的锁通过 lockInterruptibly 来实现,以便可以在一个可取消的任务中调用它。lockInterruptibly 方法能够在获取锁的同时保持对中断的响应,并且他包含在 Lock 中,因此无须创建其他类型的不可中断阻塞机制。
应该是再说说当在获取锁的时候(调用 lockInterruptibly 方法时),如果线程中断了可以立即响应。
public boolean send(String message) throws InterruptedException {
lock.lockInterruptibly();
try{
return cancel(message);
} finally {
lock.unlock();
}
}
public boolean cancel(String message) throws InterruptedException {
...
}
1.3 性能与公平性
在 Java 6.0 及以后,内置锁的性能与 Lock 锁已经相差无几。
Lock 锁中非公平性的锁的性能要高于公平性锁,原因在于:在恢复一个被挂起的线程与该线程真正开始运行之间存在严重延迟。
如线程A持有一个锁,B因尝试获取这个锁而被挂起,当A释放锁时,B将被唤醒并且再次尝试获取锁,与此同时线程C也会请求这个锁,则C有可能在B在被完全唤醒之前就获取到了锁并执行结束后再释放锁,此时就会出现 B 获取锁的时刻并没有被延迟,C 更早的获取到锁并执行结束任务。
在锁的选择上如果不是为了 Lock 锁的可中断性、可定时、可轮询的特性,更推荐使用 内置锁,因为内置锁是 JVM 的内置功能,未来更有可能进行优化,另外,Lock 锁的使用会稍显复杂。
2. 读写锁 (ReadWriteLock)
ReentrantLock 是标准的互斥锁。但互斥锁是一种保守的加锁策略,他避免了 读-写 冲突 和 写-写 冲突,但是也避免了 读-读 冲突。而在大量 读 操作的场景下,可以放宽锁的使用限制,使用读写锁(ReadWriteLock):一个资源可以被同时读,或者一个写操作。
public interface ReadWriteLock {
/**
* Returns the lock used for reading.
*
* @return the lock used for reading
*/
Lock readLock();
/**
* Returns the lock used for writing.
*
* @return the lock used for writing
*/
Lock writeLock();
}
ReadWriteLock 中存在一些可选实现:
- 释放优先:当一个写入操作释放写入锁时,并且队列中同时存在读线程和写线程,那么应该优先选择读线程、写线程还是最先发出请求的线程。
- 读线程插队:如锁是由读线程持有,但是有写线程在等待,那么新到达的线程是否能立即获取访问权?还是应该在写线程后面等待?如果允许读线程插队到写线程之前,会提高并发度,但是可能会造成写线程发生饥饿现象。
- 重入性:读锁和写锁是否是重入的
- 降级:如果一个线程持有锁,那么他能否在不释放锁的情况下获取该读取锁?这可能会使得写入锁被降级为读取锁,同时不允许其他写线程修改被保护的资源。
- 升级:读取锁能否优先于其他正在等待的读线程和写线程而升级为一个写入锁?在大多数的 读-写 实现中并不支持升级,因为如果没有显示的升级操作,那么很容易造成死锁。(如果两个读线程都视图都试图升级为写入锁,那么二者都不会释放读取锁。)
ReentrantReadWriteLock 为这两种锁都提供了可重入的加锁语义,同时在构建的时候也可以选择是公平锁或非公平锁(默认非公平锁)。同时 ReentrantReadWriteLock 中 写线程降级为读线程时可以的,但是读线程不能升级为写线程(因为会造成死锁)。
十、构建自定义同步工具
第十四章内容
1. 显示 Condition 对象
每个Java对象都可以作为一个锁,每个对象同样也可以作为一个条件队列。如下,通过 Object 的 wait、nofity、notifyAll 方法可以作为内部条件队列的API。
Object.wait 方法会释放锁,并且请求操作系统挂起当前线程,从而使其他线程能够获得这个锁并修改对象状态。
在调用 notify 时,JVM 会从这个队列上等待的多个线程中选择一个来唤醒,而调用 notifyAll 则会唤醒所有在这个条件队列上等待的线程
public class BoundedBuffer<V> extends BaseBoundedBuffer<V> {
public BoundedBuffer(int size) {
super(size);
}
public synchronized void put(V v) {
while(isFull()){
wait();
}
doPut(v);
notifyAll();
}
public synchronized V take(){
while (isEmpty()){
wait();
}
V v = doTake();
notifyAll();
return v;
}
}
上述Demo 中使用内置锁存在一些缺陷,如:每个内置锁都只能有一个相关联的条件队列,因此多个线程可能在同一个条件队列上等待不同的条件谓词,并且在常见的加锁模式下公开条件队列对象。这些因素都使得无法满足在使用 notifyAll 时所有等待线程未同一类型的需求。
对于每个 Lock 可以有任意数量的 Condition 对象。 Condition 对 Object 进行了扩展,因此在 Condition 对象中,与 wait、notify、notifyAll 方法对应的分别是 await 、signal、signalAll。通过 Confition 对象,可以通过类似下面的方式构建更灵活的锁对象。
protected final Lock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
2. AQS
AQS 有多处使用,其中控制的 state 根据使用类的不同有不同的用途:
- ReentrantLock用来表示所有者线程重复获取该锁的次数
- Semaphore 用来表示剩余许可数量
- FutureTask 用来表示任务的状态(尚未开始、正在运行、已完成以及已取消)
在需要使用 AQS 功能的时候,可以定义一个类将功能委托给 AQS,而不是直接继承AQS,这样可以有效控制避免暴露给外部的 API
十一、原子变量与非阻塞同步机制
第十五章内容
1. CAS
CAS最主要的缺点是:它将使调用者来处理竞争问题(通过重试、回退、放弃),而在锁中能自动处理竞争问题(线程获得之前一直阻塞)。
CAS的性能会随着处理器数量的不同而变化很大,单 CPU 时效率会更高。
当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其他线程都将失败。然而,失败的线程并不会被挂起(这与获取锁的情况不同:当获取锁失败时,线程将会被挂起),而是被告知在这次竞争中失败,并可以再次尝试。由于一个线程在竞争 CAS 时失败不会阻塞,因此它可以觉得是否重新尝试,或者执行一些恢复操作,也或者不执行任何操作。
2. 非阻塞算法
在基于锁的算法中可能会发生各种活跃性故障。如果线程在持有锁时由于阻塞IO,内存页缺失或其他延迟而导致推迟执行,那么很可能所有线程都不能继续执行下去。如果在某种算法中,一个线程的失败或挂起不会导致其他线程也失败或挂起,那么这种算法就被称为非阻塞算法。如果在算法的每个步骤中都存在某个线程能够执行下去,那么这种算法也被称为无锁(Lock-Free)算法。如果在算法中仅将CAS用于协调线程之间的操作,并且能正确地实现,那么它既是一种无阻塞算法,又是一种无锁算法。无竞争的CAS通常都能执行成功,并且如果有多个线程竞争同一个CAS,那么总会有一个线程在竞争中胜出并执行下去。在非阻塞算法中通常不会出现死锁和优先级反转问题(但可能会出现饥饿和活锁问题,因为在算法中会反复地重试)。在许多常见的数据结构中都可以使用非阻塞算法,包括栈、队列、优先队列以及散列表等。
十二、Java 内存模型
第十六章内容
1. Happens-Before 规则
JVM 为程序中所有的操作定义了一个偏序关系,成为 Happens-Before 。要想保证执行操作 B 的线程看到 操作 A 的接口,那么 A B 之间必须满足 Happens-Before 关系,如果两个操作之间缺乏 Happens-Before 关系,那么 JVM 可以对他们任意地重排序。
- 程序顺序规则 :如果程序中操作A在操作B之前,那么在线程中A操作将在B操作之前执行。
- 监视器锁规则 :在监视器锁上的解锁操作必须在同一个监视器锁上的加锁操作之前执行。
- volatile 变量规则 :对volatile 变量的写入操作必须在对该变量的读操作之前执行。
- 线程启动规则 :在线程上对Thread.Start的调用必须在该线程中执行任何操作之前执行。
- 线程结束规则 :线程中的任何操作都必须在其他线程检测到该线程已经结束之前执或者从 Thread.join 中成功返回,或者在调用Thread isAlive时返回 false。
- 中断规则 :当一个线程在另一个线程上调用 interrupt 时,必须在被中断线程检测到 interrupt 调用之前执行(通过抛出IntenuptedException,或者调用isInterrupted 和interrupted)
- 终结器规则 :对象的构造函数必须在启动该对象的终结器之前执行完成。
- 传递性 :如果操作A在操作B之前执行,并且操作B在操作之前执行,那么操作A 必须在操作℃之前执行。
当缺少 Happens-Before 关系时,就可能出现重排序问题,导致在没有充分同步的情况下发布一个对象会导致另一个线程看到一个纸杯部分构造的对象。
参考内容
https://blog.youkuaiyun.com/weixin_43978420/article/details/117078948
https://blog.youkuaiyun.com/feiying101/article/details/138291778