本章包括涉及 Java 并发的 14 个问题。我们将从线程生命周期以及对象级和类级锁定的几个基本问题开始。然后我们继续讨论 Java 中线程池的一系列问题,包括 JDK8 工作线程池。在那之后,我们有关于Callable
和Future
的问题。然后,我们将几个问题专门讨论 Java 同步器(例如,屏障、信号量和交换器)。在本章结束时,您应该熟悉 Java 并发的主要坐标,并准备好继续处理一组高级问题。
问题
使用以下问题来测试您的并发编程能力。我强烈建议您在使用解决方案和下载示例程序之前,先尝试一下每个问题:
- 线程生命周期状态:编写多个程序,捕捉线程的每个生命周期状态。
- 对象级与类级的锁定:写几个例子来举例说明通过线程同步实现对象级与类级的锁定。
- Java 中的线程池:简要概述 Java 中的线程池。
- 单线程线程池:编写一个程序,模拟一条装配线,用两个工作器来检查和打包灯泡。
- 固定线程数的线程池:编写一个程序,模拟一条装配线,使用多个工作器检查和打包灯泡。
- 缓存和调度线程池:编写一个程序,模拟装配线,根据需要使用工作器检查和打包灯泡(例如,调整打包机的数量(增加或减少)以吸收检查器产生的传入流量)。
- 偷工线程池:编写依赖偷工线程池的程序。更准确地说,编写一个程序,模拟一条装配线来检查和打包灯泡,如下所示:检查在白天进行,打包在晚上进行。检查过程导致每天有 1500 万只灯泡排队。
Callable
和Future
:用Callable
和Future
编写模拟灯泡检查打包流水线的程序。- 调用多个
Callable
任务:编写一个模拟装配线的程序,对灯泡进行检查和打包,如下所示:检查在白天进行,打包在晚上进行。检查过程导致每天有 100 个灯泡排队。包装过程应一次包装并归还所有灯泡。也就是说,我们应该提交所有的Callable
任务,等待它们全部完成。 - 锁存器:编写一个依赖
CountDownLatch
的程序来模拟服务器的启动过程。服务器在其内部服务启动后被视为已启动。服务可以同时启动并且相互独立。 - 屏障:编写一个依赖
CyclicBarrier
来模拟服务器启动过程的程序。服务器在其内部服务启动后被视为已启动。服务可以同时启动(这很费时),但它们是相互依赖的—因此,一旦准备好启动,就必须一次启动所有服务。 - 交换器:编写一个程序,模拟使用
Exchanger
,一条由两名工作器组成的灯泡检查打包流水线。一个工作器(检查人员)正在检查灯泡,并把它们放进篮子里。当篮子装满时,工作器将篮子交给另一个工作器(包装工),他们从另一个工作器那里得到一个空篮子。这个过程不断重复,直到装配线停止。 - 信号量:编写一个程序,模拟每天在理发店使用一个
Semaphore
。我们的理发店一次最多只能接待三个人(只有三个座位)。当一个人到达理发店时,他们试着坐下。理发师为他们服务后,这个人就把座位打开。如果一个人在三个座位都坐满的时候到达理发店,他们必须等待一定的时间。如果这段时间过去了,没有座位被释放,他们将离开理发店。 - 移相器:编写一个依赖
Phaser
的程序,分三个阶段模拟服务器的启动过程。服务器在其五个内部服务启动后被视为已启动。在第一阶段,我们需要同时启动三个服务。在第二阶段,我们需要同时启动另外两个服务(只有在前三个服务已经运行的情况下才能启动)。在第三阶段,服务器执行最后一次签入,并被视为已启动。
解决方案
以下各节介绍上述问题的解决方案。记住,解决一个特定问题通常不是只有一种正确的方法。另外,请记住,这里显示的解释仅包括解决问题所需的最有趣和最重要的细节。下载示例解决方案以查看更多详细信息,并在这个页面中试用程序。
199 线程生命周期状态
Java 线程的状态通过Thread.State
枚举表示。Java 线程的可能状态如下图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SM3wWnYZ-1657345732714)(https://github.com/apachecn/apachecn-java-zh/raw/master/docs/java-coding-prob/img/49d6745e-1417-4768-aad8-ea8ab6832ed6.png)]
不同的生命周期状态如下:
- 新建状态
- 可运行状态
- 阻塞状态
- 等待状态
- 定时等待状态
- 终止状态
让我们在下面的部分学习所有不同的状态。
新建状态
如果 Java 线程已创建但未启动,则该线程处于新建状态(线程构造器以新建状态创建线程)。这是它的状态,直到start()
方法被调用。本书附带的代码包含几个代码片段,这些代码片段通过不同的构造技术(包括 Lambda)揭示了这种状态。为简洁起见,以下只是其中一种结构:
public class NewThread { public void newThread() { Thread t = new Thread(() -> {}); System.out.println("NewThread: " + t.getState()); // NEW } } NewThread nt = new NewThread(); nt.newThread();
可运行状态
通过调用start()
方法获得从新建到可运行的转换。在此状态下,线程可以正在运行或准备运行。当它准备好运行时,线程正在等待 JVM 线程调度器为它分配运行所需的资源和时间。一旦处理器可用,线程调度器就会运行线程。
下面的代码片段应该打印RUNNABLE
,因为我们在调用start()
之后打印线程的状态。但由于线程调度器的内部机制,这一点无法保证:
public class RunnableThread { public void runnableThread() { Thread t = new Thread(() -> {}); t.start(); // RUNNABLE System.out.println("RunnableThread : " + t.getState()); } } RunnableThread rt = new RunnableThread(); rt.runnableThread();
阻塞状态
当线程试图执行 I/O 任务或同步块时,它可能会进入阻塞状态。例如,如果一个线程t1
试图进入另一个线程t2
正在访问的同步代码块,那么t1
将保持在阻塞状态,直到它能够获得锁为止。
此场景在以下代码片段中形成:
- 创建两个线程:
t1
和t2
。 - 通过
start()
方法启动t1
:
t1
将执行run()
方法并获取同步方法syncMethod()
的锁。- 因为
syncMethod()
有一个无限循环,所以t1
将永远留在里面。
- 2 秒(任意时间)后,通过
start()
方法启动t2
t2
将执行run()
代码,由于无法获取syncMethod()
的锁,最终进入阻塞状态。
代码段如下:
public class BlockedThread { public void blockedThread() { Thread t1 = new Thread(new SyncCode()); Thread t2 = new Thread(new SyncCode()); t1.start(); Thread.sleep(2000); t2.start(); Thread.sleep(2000); System.out.println("BlockedThread t1: " + t1.getState() + "(" + t1.getName() + ")"); System.out.println("BlockedThread t2: " + t2.getState() + "(" + t2.getName() + ")"); System.exit(0); } private static class SyncCode implements Runnable { @Override public void run() { System.out.println("Thread " + Thread.currentThread().getName() + " is in run() method"); syncMethod(); } public static synchronized void syncMethod() { System.out.println("Thread " + Thread.currentThread().getName() + " is in syncMethod() method"); while (true) { // t1 will stay here forever, therefore t2 is blocked } } } } BlockedThread bt = new BlockedThread(); bt.blockedThread();
下面是一个可能的输出(线程的名称可能与此处不同):
Thread Thread-0 is in run() method Thread Thread-0 is in syncMethod() method Thread Thread-1 is in run() method BlockedThread t1: RUNNABLE(Thread-0) BlockedThread t2: BLOCKED(Thread-1)
等待状态
等待另一个线程t2
完成的线程t1
处于等待状态。
此场景在以下代码片段中形成:
- 创建线程:
t1
。 - 通过
start()
方法启动t1
。 - 在
t1
的run()
方法中:
- 创建另一个线程:
t2
。 - 通过
start()
方法启动t2
。 - 当
t2
运行时,调用t2.join()
——由于t2
需要加入t1
(也就是说t1
需要等待t2
死亡),t1
处于等待状态。
- 在
t2
的run()
方法中t2
打印t1
的状态,应该是等待(打印t1
状态时t2
正在运行,所以t1
正在等待)。
代码段如下:
public class WaitingThread { public void waitingThread() { new Thread(() -> { Thread t1 = Thread.currentThread(); Thread t2 = new Thread(() -> { Thread.sleep(2000); System.out.println("WaitingThread t1: " + t1.getState()); // WAITING }); t2.start(); t2.join(); }).start(); } } WaitingThread wt = new WaitingThread(); wt.waitingThread();
定时等待状态
等待另一个线程t2
完成显式时间段的线程t1
处于定时等待状态。
此场景在以下代码片段中形成:
- 创建线程:
t1
。 - 通过
start()
方法启动t1
。 - 在
t1
的run()
方法中,增加 2 秒的睡眠时间(任意时间)。 - 当
t1
运行时,主线程打印t1
状态,该状态应为定时等待,因为t1
处于两秒后过期的sleep()
中。
代码段如下:
public class TimedWaitingThread { public void timedWaitingThread() { Thread t = new Thread(() -> { Thread.sleep(2000); }); t.start(); Thread.sleep(500); System.out.println("TimedWaitingThread t: " + t.getState()); // TIMED_WAITING } } TimedWaitingThread twt = new TimedWaitingThread(); twt.timedWaitingThread();
终止状态
成功完成任务或异常中断的线程处于终止状态。模拟起来非常简单,如下面的代码片段(应用的主线程打印线程的状态,t
——发生这种情况时,线程t
已经完成了它的工作):
public class TerminatedThread { public void terminatedThread() { Thread t = new Thread(() -> {}); t.start(); Thread.sleep(1000); System.out.println("TerminatedThread t: " + t.getState()); // TERMINATED } } TerminatedThread tt = new TerminatedThread(); tt.terminatedThread();
为了编写线程安全类,我们可以考虑以下技术:
- 没有状态(类没有实例和
static
变量) - 有状态,但不共享(例如,通过
Runnable
、ThreadLocal
等使用实例变量) - 有状态,但状态不可变
- 使用消息传递(例如,作为 Akka 框架)
- 使用
synchronized
块 - 使用
volatile
变量 - 使用
java.util.concurrent
包中的数据结构 - 使用同步器(例如,
CountDownLatch
和Barrier
) - 使用
java.util.concurrent.locks
包中的锁
200 对象级与类级锁定
在 Java 中,标记为synchronized
的代码块一次可以由一个线程执行。由于 Java 是一个多线程环境(它支持并发),因此它需要一个同步机制来避免并发环境特有的问题(例如死锁和内存一致性)。
线程可以在对象级或类级实现锁。
对象级别的锁定
对象级的锁定可以通过在非static
代码块或非static
方法(该方法的对象的锁定对象)上标记synchronized
来实现。在以下示例中,一次只允许一个线程在类的给定实例上执行synchronized
方法/块:
- 同步方法案例:
public class ClassOll { public synchronized void methodOll() { ... } }
- 同步代码块:
public class ClassOll { public void methodOll() { synchronized(this) { ... } } }
- 另一个同步代码块:
public class ClassOll { private final Object ollLock = new Object(); public void methodOll() { synchronized(ollLock) { ... } } }
类级别的锁定
为了保护static
数据,可以通过标记static
方法/块或用synchronized
获取.class
引用上的锁来实现类级锁定。在以下示例中,一次只允许运行时可用实例之一的一个线程执行synchronized
块:
synchronized static
方法:
public class ClassCll { public synchronized static void methodCll() { ... } }
.class
同步块:
public class ClassCll { public void method() { synchronized(ClassCll.class) { ... } } }
- 同步的代码块和其他
static
对象的锁定:
public class ClassCll { private final static Object aLock = new Object(); public void method() { synchronized(aLock) { ... } } }
很高兴知道
以下是一些暗示同步的常见情况:
- 两个线程可以同时执行同一类的
synchronized static
方法和非static
方法(参见P200_ObjectVsClassLevelLocking
App 的OllAndCll
类)。这是因为线程在不同的对象上获取锁。 - 两个线程不能同时执行同一类的两个不同的
synchronized static
方法(或同一synchronized static
方法)(检查P200_ObjectVsClassLevelLocking
应用的TwoCll
类)。这不起作用,因为第一个线程获得了类级锁。以下组合将输出staticMethod1(): Thread-0
,因此,只有一个线程执行一个static synchronized
方法:
TwoCll instance1 = new TwoCll(); TwoCll instance2 = new TwoCll();
- 两个线程,两个实例:
new Thread(() -> { instance1.staticMethod1(); }).start(); new Thread(() -> { instance2.staticMethod2(); }).start();
- 两个线程,一个实例:
new Thread(() -> { instance1.staticMethod1(); }).start(); new Thread(() -> { instance1.staticMethod2(); }).start();
- 两个线程可以同时执行非
synchronized
、synchronized static
和synchronized
非static
方法(检查P200_ObjectVsClassLevelLocking
应用的OllCllAndNoLock
类)。 - 从需要相同锁的同一类的另一个
synchronized
方法调用synchronized
方法是安全的,因为synchronized
是可重入的(只要是相同的锁,第一个方法获取的锁也会用于第二个方法)。检查P200_ObjectVsClassLevelLocking
应用的TwoSyncs
类。
根据经验,synchronized
关键字只能用于static
/非static
方法(不是构造器)/代码块。避免同步非final
字段和String
文本(通过new
创建的String
实例是可以的)。
Java 编程问题:十、并发-线程池、可调用对象和同步器2https://developer.aliyun.com/article/1426162