Java 线程状态探索

485 阅读8分钟

线程处于是否处于阻塞状态,我们真正想知道的是它是否还继续占用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.joinLockSupport.park 之一后,等待其他线程触发特定条件比如调用 Object.notify 等方法或执行完成
TIMED_WAITING线程指定时间内等待另一个线程执行特定行为同上,不过方法带有时间限制参数;额外调用 Thread.sleep 方法后
TERMINATED线程已退出线程完成执行

一个线程同一时刻只会处于其中一个状态,这些状态只是虚拟机状态不反应操作系统状态。

接下来探索调用各种常用的方法时线程处于什么状态,实验所用的方法就是当执行线程陷入“阻塞情况”时,用其他线程打印执行线程状态,JDK版本选择17

JUC 中的锁和信号量

当我们使用 JUC 中的信号量时,底层也是使用 LockSupport.park 方法,所以调用相关阻塞方法时线程也是进入到 WAITINGTIMED_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 方法获取异步结果时,根据有无带时间限制参数,线程处于 WAITINGTIMED_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

ServerSocketChannelSocketChannel 可以通过 configureBlocking(boolean) 方法配置阻塞模式,这样当调用 acceptconnect 会阻塞执行或非阻塞直接返回(这种情况一般会向 Selector 中注册相关的 SelectionKey 再做处理)。

Selectorselect 方法有很多变体,除了无参之外还有加了等待时间和/或 Consumer<SelectionKey> 的。 select 方法调用时当前线程处于 RUNNABLE 状态,占用 CPU 时间片。

SocketChannelread 方法在非阻塞模式下,会立即读取可用数据并返回;在阻塞模式下,会阻塞读取缓冲区里所有的字节直到至少读取一个字节,阻塞情况下当前线程处于 RUNNABLE 状态,占用 CPU 时间片。

对于 ServerSocketChannelSocketChannel 来说,应尽量结合 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

其他的场景后续更新。。。


总结

可以看出,涉及信号量的操作都会让线程处于 BLOCKEDWAITING 状态,这时不会占用CPU时间片,当然频繁这样做也会增加任务处理的时间。

对于阻塞IO操作,线程还是处于 RUNNABLE 状态,会占用CPU时间片,这样做让其他线程做真正有用事情的时间就少了。

阻塞IO模型中,数据从外部网络操作系统内核再到程序内存空间都是阻塞状态,本地的数据传输还好挺快,但是外部网络到操作系统内核是个很漫长的过程,阻塞IO调用时这将让当前线程占用很长的CPU时间片但是什么事情也不做。而且如果要并发执行大量阻塞IO调用时,这样做不得不用更多的线程来减少总时间,这样做就更浪费CPU资源了。

所以现在很流行 Reactor 模型,一个或多个线程监听所有的IO事件(是否有数据到达操作系统内核),这样用户线程就不用在 数据从外部网络到操作系统内核 这个耗时的环节做无用功了。

无论怎样,永远不要阻塞IO事件循环线程。

如下是 Undertow 中线程模型的介绍:

image.png

实际的开发工作中,Web框架们把IO工作做的很好,一般我们只需要在 requestHandler 里写自己的业务代码就可以了。不过需要注意的是,Web框架也有同步(如 Spring MVC)和响应式(如 Spring WebFluxVert.X)之分,这里的同步不是说用了阻塞IO模型,而是请求处理上用了每线程一请求模型,对开发者来说就是 requestHandler 里的代码应该是同步阻塞的还是异步非阻塞的。

根据自己用的 RPC 框架或 Web 框架的同步特性或响应式特性,在 requestHandler 里使用其他IO操作(比如数据库调用、RPC调用等)时,选择改用同步IO方法还是异步IO方法,在响应式特性的框架中,一定要尽量使用异步IO方法,避免阻塞线程。