线程处于是否处于阻塞状态,我们真正想知道的是它是否还继续占用CPU时间片但什么事都没有做。当我们说不喜欢IO阻塞线程时,是因为它仍会被操作系统调度占用CPU时间片,但是这期间什么事情都没有发生。
这篇文章将结合源码和代码实践来探索在 Java 中执行各种常见“阻塞”方法时线程的状态,来找出哪些情况下线程会占用CPU时间片,哪些不会,来更有效的编程和使用异步模式。
🔥当前基于 JDK 17。在 JDK 21 中更新了平台线程和虚拟线程会有不一样的情况。
Java 线程状态
java.lang.Thread.State 线程状态源码,详细介绍了线程的各个状态:
| 状态 | 介绍 | 相关方法 | 是否占用CPU时间片 |
|---|---|---|---|
| NEW | 线程还未启动 | 构造Thread对象后调用 start 前 | 否 |
| RUNNABLE | 线程正在JVM中执行 | 线程正常执行时 | 是 |
| BLOCKED | 线程阻塞中并等待一个 monitor lock | 线程进入 synchronized 代码块/方法时,或调用 Object.wait 方法后重新进入 synchronized 代码块/方法时 | 否 |
| WAITING | 线程无限期等待另一个线程执行特定行为 | 线程调用 不含时间限制的Object.wait、不含时间限制的Thread.join、LockSupport.park 之一后,等待其他线程触发特定条件比如调用 Object.notify 等方法或执行完成 | 否 |
| TIMED_WAITING | 线程指定时间内等待另一个线程执行特定行为 | 同上,不过方法带有时间限制参数;额外调用 Thread.sleep 方法后 | 否 |
| TERMINATED | 线程已退出 | 线程完成执行 | 否 |
一个线程同一时刻只会处于其中一个状态,这些状态只是虚拟机状态不反应操作系统状态。
接下来探索调用各种常用的方法时线程处于什么状态,实验所用的方法就是当执行线程陷入“阻塞情况”时,用其他线程打印执行线程的状态,JDK版本选择17。
JUC 中的锁和信号量
当我们使用 JUC 中的锁或信号量时,底层也是使用 LockSupport.park 方法,所以调用相关阻塞方法时线程也是进入到 WAITING 或 TIMED_WAITING 状态,不会占用 CPU 时间片。
代码实践,在一个线程中调用 CountDownLatch##wait 后线程的状态:
CountDownLatch latch = new CountDownLatch(2);
Thread mainThread = Thread.currentThread();
Executors.newSingleThreadExecutor().submit(() -> {
sleep(100);
System.out.println(mainThread + ": " + mainThread.getState());
});
latch.await();
控制台输出:
Thread[main,5,main]: WAITING
Future#get 方法
当调用 Future#get 方法获取异步结果时,根据有无带时间限制参数,线程处于 WAITING 或 TIMED_WAITING 状态,不会占用 CPU 时间片。
代码实践:
Future<String> future = Executors.newFixedThreadPool(2).submit(() -> {
sleep(500);
return "data";
});
Thread mainThread = Thread.currentThread();
Executors.newSingleThreadExecutor().submit(() -> {
sleep(200);
System.out.println(mainThread + ": " + mainThread.getState());
});
var data = future.get();
控制台输出:
Thread[main,5,main]: WAITING
java.net.Socket 和 ServerSocket
ServerSocket 调用 accept 方法时,或 Socket 读取数据时,线程处于 RUNNABLE 状态,占用 CPU 时间片。这就是我们常说的阻塞IO,它占用 CPU 资源,但是只是空等,没有做任何事情。
代码实践:
static void serverSocket() throws Exception {
Thread.currentThread().setName("ServerSocket-Thread-Accept");
printThreadStateAfter(Thread.currentThread(), 200);
ServerSocket serverSocket = new ServerSocket(6060);
while (true) {
// 这里会IO阻塞,会打印这时候的线程状态
Socket socket = serverSocket.accept();
var in = socket.getInputStream();
var out = socket.getOutputStream();
byte[] request = in.readNBytes(in.available());
sleep(500);
var data = "Response-" + ThreadLocalRandom.current().nextInt(100);
out.write(data.getBytes());
out.flush();
socket.close();
}
}
public static void main(String[] args) throws Exception {
executor.execute(() -> {
try {
serverSocket();
} catch (Exception e) {
e.printStackTrace();
}
});
sleep(250);
Socket socket = new Socket("127.0.0.1", 6060);
Thread.currentThread().setName("Socket-Thread");
printThreadStateAfter(Thread.currentThread(), 350);
socket.getOutputStream().write("Request-1".getBytes());
socket.getOutputStream().flush();
// 这里会打印 Socket 读取数据时线程的状态
var response = socket.getInputStream().readAllBytes();
System.out.println("Get response: " + new String(response));
socket.close();
}
控制台输出:
Thread[ServerSocket-Thread-Accept,5,main]: RUNNABLE
Thread[Socket-Thread,5,main]: RUNNABLE
Get response: Response-87
java.nio.channels.Selector 和 SocketChannel
ServerSocketChannel 和 SocketChannel 可以通过 configureBlocking(boolean) 方法配置阻塞模式,这样当调用 accept 和 connect 会阻塞执行或非阻塞直接返回(这种情况一般会向 Selector 中注册相关的 SelectionKey 再做处理)。
Selector 的 select 方法有很多变体,除了无参之外还有加了等待时间和/或 Consumer<SelectionKey> 的。 select 方法调用时当前线程处于 RUNNABLE 状态,占用 CPU 时间片。
SocketChannel 的 read 方法在非阻塞模式下,会立即读取可用数据并返回;在阻塞模式下,会阻塞读取缓冲区里所有的字节直到至少读取一个字节,阻塞情况下当前线程处于 RUNNABLE 状态,占用 CPU 时间片。
对于 ServerSocketChannel 和 SocketChannel 来说,应尽量结合 Selector 使用非阻塞模式,这样可以减少线程的IO阻塞情况。
代码实践:
非阻塞时间服务器
public class NBTimeServer {
public static final int DEFAULT_PORT = 8900;
private int port;
private final AtomicBoolean started = new AtomicBoolean(false);
public NBTimeServer() {
port = DEFAULT_PORT;
}
public NBTimeServer(int port) {
this.port = port;
}
public void start() throws IOException{
Selector selector = Selector.open();
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking(false);
ssc.register(selector, SelectionKey.OP_ACCEPT);
started.set(true);
Thread.currentThread().setName("TimeServer-Thread");
ThreadTest.printThreadStateAfter(Thread.currentThread(), 100);
ssc.socket().bind(new InetSocketAddress(port));
while (started.get()) {
// 这里会打印当前线程状态
selector.select(100);
var keys = selector.selectedKeys().iterator();
while(keys.hasNext()) {
var key = keys.next();
keys.remove();
var nextReady = (ServerSocketChannel) key.channel();
var out = nextReady.accept().socket().getOutputStream();
// 延迟 200 毫秒 返回响应
ThreadTest.sleep(200);
out.write(LocalDateTime.now().toString().getBytes());
out.flush();
}
}
selector.close();
ssc.close();
}
public void stop() {
started.set(false);
}
public static void main(String[] args) throws Exception{
var timeServer = new NBTimeServer(6600);
timeServer.start();
}
}
在IO事件循环线程里,每次循环时查询IO事件这个步骤阻塞是正常和可接受的。另外这里代码作为演示,实际上IO事件循环线程中不应该出现别的阻塞操作,比如代码里的 sleep,和使用 Socket 这个阻塞IO对象,应该用 SocketChannel 的非阻塞模式 + 注册到 Selector 使用,当然具体的请求处理也可以用另外的线程组——一般被称为工作线程组。
控制台输出:
Thread[TimeServer-Thread,5,main]: RUNNABLE
时间查询客户端
public class TimeQuery {
private static int DAYTIME_PORT = 13;
private static Charset charset = StandardCharsets.US_ASCII;
private static CharsetDecoder decoder = charset.newDecoder();
private static ByteBuffer dbuf = ByteBuffer.allocateDirect(1024);
private static void query(String host, int port) throws IOException {
try (SocketChannel sc = SocketChannel.open()) {
InetSocketAddress isa = new InetSocketAddress(
InetAddress.getByName(host), port);
sc.connect(isa);
Thread.currentThread().setName("QueryClient-Thread");
ThreadTest.printThreadStateAfter(Thread.currentThread(), 50);
// 这里会打印当前线程状态,因为服务器会延迟200ms返回结果
dbuf.clear();
sc.read(dbuf);
dbuf.flip();
CharBuffer cb = decoder.decode(dbuf);
System.out.print(isa + " : " + cb);
}
}
public static void main(String[] args) throws Exception{
query("localhost", 6600);
}
}
控制台输出:
Thread[QueryClient-Thread,5,main]: RUNNABLE
localhost/127.0.0.1:6600 : 2023-07-08T17:24:22.343768600
JDBC
JDBC 中执行查询操作时,线程处于 RUNNABLE 状态,占用 CPU 时间片。
代码实践:
try {
var conn = DriverManager.getConnection("jdbc:mysql://localhost/xxx", "xxx", "xxx");
Thread.currentThread().setName("JDBC-Thread");
printThreadStateAfter(Thread.currentThread(), 100);
var rs = conn.createStatement().executeQuery("SELECT sleep(1) as `sleep`;");
rs.next();
System.out.println("Query result: " + rs.getString("sleep"));
} catch (SQLException e) {
System.out.println(e);
}
控制台输出:
Thread[JDBC-Thread,5,main]: RUNNABLE
Query result: 0
JDK11 HttpClient
JDK11 HttpClient 中的 send 同步IO方法其实是调用 sendAsync + future.get 方法,线程处于 WAITING 状态,不占用 当前 CPU 时间片。
代码实践:
Thread.currentThread().setName("HttpClient-Thread");
printThreadStateAfter(Thread.currentThread(), 10);
HttpClient client = HttpClient.newHttpClient();
var request = HttpRequest.newBuilder(URI.create("https://juejin.cn/")).GET().build();
var response = client.send(request, BodyHandlers.discarding());
System.out.println("Response status: " + response.statusCode());
控制台输出:
Thread[HttpClient-Thread,5,main]: RUNNABLE
Response status: 200
其他的场景后续更新。。。
总结
可以看出,涉及锁、信号量的操作都会让线程处于 BLOCKED 或 WAITING 状态,这时不会占用CPU时间片,当然频繁这样做也会增加任务处理的时间。
对于阻塞IO操作,线程还是处于 RUNNABLE 状态,会占用CPU时间片,这样做让其他线程做真正有用事情的时间就少了。
阻塞IO模型中,数据从外部网络到操作系统内核再到程序内存空间都是阻塞状态,本地的数据传输还好挺快,但是外部网络到操作系统内核是个很漫长的过程,阻塞IO调用时这将让当前线程占用很长的CPU时间片但是什么事情也不做。而且如果要并发执行大量阻塞IO调用时,这样做不得不用更多的线程来减少总时间,这样做就更浪费CPU资源了。
所以现在很流行 Reactor 模型,一个或多个线程监听所有的IO事件(是否有数据到达操作系统内核),这样用户线程就不用在 数据从外部网络到操作系统内核 这个耗时的环节做无用功了。
无论怎样,永远不要阻塞IO事件循环线程。
如下是 Undertow 中线程模型的介绍:
实际的开发工作中,Web框架们把IO工作做的很好,一般我们只需要在 requestHandler 里写自己的业务代码就可以了。不过需要注意的是,Web框架也有同步(如 Spring MVC)和响应式(如 Spring WebFlux 和 Vert.X)之分,这里的同步不是说用了阻塞IO模型,而是请求处理上用了每线程一请求模型,对开发者来说就是 requestHandler 里的代码应该是同步阻塞的还是异步非阻塞的。
根据自己用的 RPC 框架或 Web 框架的同步特性或响应式特性,在 requestHandler 里使用其他IO操作(比如数据库调用、RPC调用等)时,选择改用同步IO方法还是异步IO方法,在响应式特性的框架中,一定要尽量使用异步IO方法,避免阻塞线程。