Thread

既然要学多线程,首先就要先学线程,那什么是线程呢?

进程是操作系统分配资源的最小单位,线程是操作系统调度的最小单位。(进程:公司,线程:员工,公司拥有生产材料,员工作为劳动力消耗生产材料创造价值)

  • 一个进程至少要有一个线程,线程共享进程的资源。(单人公司)

  • 进程的健壮性更好,一个进程崩溃了其他进程可以继续进行。线程因为贡献资源,所以健壮性就比较差了,可能一个线程崩溃了导致这个进程的所有线程都无法工作。(一个公司倒闭,一般不会影响其他公司。在公司里面,如果核心员工辞职可能严重影响工作)

  • 但是创建进程、结束进程、切换进程的速度慢,而线程的速度快,线程更加轻量化。(公司的创建和倒闭要走流程,员工可以快速入职和离职,而且可以招实习生)

那程序、进程之间又是什么关系呢?

程序是文件的集合,进程是操作系统和程序之间的中间层。

中间层是什么意思?

中间层的含义就是中间层。比如 java 里面的 File 类,就是一个中间层。

我们如果需要获取文件的信息,是直接读取这个文件吗?不是的,是 new 一个 File 对象,用 File 对象来关联实际存在的文件。

如果我们需要编辑文件的内容,要通过 IO 流操作文件的内容。

这里的 File 和 IO 就是中间层,java 不认识文件,只认识 File 和 IO,我们对文件的操作要借助 File 和 IO 的 API 来实现。

再说回到程序和进程,操作系统不认识程序,它只认识进程。

这个程序需要 200MB 内存,需要联网,需要调用扬声器,是通过进程这个中间层去获取资源的。

操作系统给进程分配软件和硬件资源,而不是给程序分配资源。

那并发和并行又是什么意思呢?

这个简单:

  • 并发:一个线程执行多个程序。

  • 并行:多个线程执行多个程序。

并发和并行可以同时存在。

1.创建线程的三种方式

1.继承 Thread

class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("我的线程执行了");
    }
}
    public static void main(String[] args) {
        MyThread myThread = new MyThread();
        myThread.start();
    }

MyThread 继承了 Thread,MyThread 对象就是一个线程对象了。

但是有了线程对象,这个线程还要干活啊,招个员工进来不干活怎么行。所以要重写 run 方法,run 方法里面就是线程要干的活了。

注意,不要直接调用 run 方法,而是要调用 start 方法。

  • 调用 run 方法是普通的方法调用,MyThread 对象只是一个线程对象,不是线程。

  • 调用 start 方法才能用 MyThread 对象创建一个线程,这个线程要完成 run 方法里面的工作。

2.实现 Runnable 接口

class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("我的Runnable执行了");
    }
}
    public static void main(String[] args) {
        MyRunnable myRunnable = new MyRunnable();
        Thread thread = new Thread(myRunnable);
        thread.start();
    }

Thread 对象本身就是线程对象了,而且 start 方法可以创建一个线程,我们其实只需要知道要干什么活就可以了。

所以实现 Runnable 接口就是为了重写 run 方法,在 run 方法里面安排要做什么工作、干什么活。

那我们实现了 Runnable 接口,重写了 run 方法,我们知道要打印 "我的Runnable执行了",但是线程不知道啊,所以要在 new Thread 的时候,把 Runnable 实现类对象传进入,线程才知道要做什么工作。

这个代码还有简化版本:

    public static void main(String[] args) {
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("我的Runnable执行了");
            }
        });
        thread.start();
    }

3.实现 Callable 接口

class MyCallable implements Callable<Integer> {
    @Override
    public Integer call() {
        System.out.println("我的Callable执行了");
        return 200;
    }
}
    public static void main(String[] args) throws InterruptedException, ExecutionException {
        MyCallable myCallable = new MyCallable();
        FutureTask<Integer> future = new FutureTask<>(myCallable);
        Thread thread = new Thread(future);
        thread.start();
        while (true) {
            if (future.isDone()) {
                break;
            } else {
                Thread.sleep(200);
            }
        }
        System.out.println(future.get());
    }

Callable 又是干嘛的呢?

你看啊,之前两种创建线程的方式,都可以让线程干活,但是线程就是只干活,它没有反馈啊。如果我要让它统计最近一年的内存价格走势图,它只会呆呆地干活,我不知道它有没有干完,什么时候干完的。

而 Callable 接口可以获取线程执行的结果。Callable 接口是泛型接口,这里的泛型就是线程执行结果返回值的类型。

同时,Callable 接口里面的 call 方法是有返回值的,返回值就是 Callable 接口指定的泛型类型。

那我知道了,接下来就是 new Thread(new MyCallable()) 是吧?

其实不是啊,要把 Callable 实现类对象放进 FutureTask 对象里面,再把 FutureTask 对象放进 Thread 对象中。

为什么要这么做呢?

因为我们要获取线程的执行结果啊,Thread 对象调用一个 start 方法创建线程,让线程干活,没有提供获取线程执行结果的方法。

所以我们要用 FutureTask 获取线程的执行结果,Future 就是未来的意思嘛,那未来的线程的执行结果就要用 FutureTask 来获取啊,很合理吧。

  • 把 Callable 实现类对象放进 FutureTask 对象,FutureTask 对象知道线程要干嘛,可以通过 FutureTask 对象获取线程执行结果。

  • 把 FutureTask 对象放进 Thread 对象,Thread 对象从 FutureTask 对象中获取自己的工作。

  • thread.start(),创建一个线程,线程开始干活。

  • future.get(),通过这个方法,FutureTask 对象可以获取线程的执行结果。

  • 但是有个问题啊,这个 get 方法是同步等待的,如果线程干了 1 个小时都没干完,那这个 get 方法就会阻塞一个小时,程序就不会向下执行了。所以我们可以用 isDone 方法进行判断,isDone() 返回 false 说明线程还没干完不需要调用 get 方法,isDone() 返回 true 才调用 get 方法。

2.线程的状态

Thread 类里面有一个内部枚举类 State,State 记录了线程的状态:

    public enum State {
        NEW,
        RUNNABLE,
        BLOCKED,
        WAITING,
        TIMED_WAITING,
        TERMINATED;
    }
  • new:new 了一个 Thread 对象,还没有调用 start 方法创建线程

  • runnable:线程正在执行、线程等待被调度

  • blocked:线程争抢锁失败,等待锁释放

  • waiting:线程不是因为争抢锁失败而等待,有其他等待原因,或者说 waiting 是线程获得锁的等待

  • timed_waiting:waiting 状态如果不被唤醒,可以无限等待下去,timed_waiting 可以指定等待时间,过了这个时间可以自动结束 timed_waiting 状态,需要获得时间片才能执行

  • terminated:线程正常结束、异常中断

3.线程的方法

String getName():获取线程的名字

String setName(String):设置线程的名字

long threadId():获取线程的 id

long getId():同上,since 19 被废弃

void setPriority(int):设置线程优先级

int getPriority():获取线程优先级

  • 线程的优先级从 1 到 10,默认优先级是 5,操作系统更倾向于调度优先级高的线程,但不是绝对。

setDaemon(boolean):设置守护线程

  • 这里的守护线程不是只守护某一个线程,而是守护非守护线程。

  • 一共就两种线程,一种是守护线程,不是守护线程的就是非守护线程咯。

  • 非守护线程执行结束,守护线程即使没有执行完也会结束。既然守护线程也会执行,所以守护线程一样会争抢 CPU。

  • setDaemon(true) 必须在 start 方法前调用,否则抛 IllegalThreadStateException。

void wait():线程进入等待队列,释放锁

void wait(long):线程进入等待队列,释放锁。若线程可以在等待时间内被唤醒,如果超过等待时间则自动醒来,需要获得时间片才能执行

void notify():随机唤醒阻塞队列的一个线程

void notifyAll():唤醒阻塞队列的所有线程

  • wait、notify、notifyAll 都是 Object 的成员方法,必须要获得锁对象才能调用,否则抛 IllegalMonitorStateException。

  • wait() 进入 waiting 状态,wait(long) 进入 timed_waiting 状态。

void interrupt():根据打断标志打断未被打断的线程,已打断的线程无法再次打断

boolean isInterrupted():返回打断标志,true 表示被打断,false 表示没有打断

  • 打断 waiting、timed_waiting 状态的线程,抛 InterruptedException,打断标志 = false

  • 打断 runnable、blocked 状态的线程,不会抛异常,线程不会停止,打断标志 = true

void join()

void join(long)

  • a.join(),等 a 线程执行完之后程序才能向下执行

  • a.join(long),a 线程如果超过指定时间都没有结束,程序会继续向下执行

static Thread currentThread():返回当前线程

static void sleep(long):线程睡眠指定时间(毫秒),不释放锁

static void yield():线程出让 CPU,一直是 runnable,不过下一次可能又是它被调度,因为程序计数器的存在,不会再执行 yield 方法

  • sleep 方法:runnable -> timed_waiting,睡眠结束后需要获得时间片才能执行

  • sleep(0):虽然只是睡眠 0 毫秒,但是 CPU 会下放这个线程,调度新线程。

  • sleep(0) = yield()

悲观锁

1.synchronized

synchronized 有两种使用方式:

  • synchronized 代码块

  • synchronized 方法

synchronized 代码块可以手动指定锁对象,锁对象必须是唯一的。

synchronized 方法不能手动指定锁对象:

  • synchronized 锁住的是成员方法,锁对象是调用这个成员方法的对象

  • synchronized 锁住的是静态方法,锁对象是这个静态方法所属的类的 Class 对象

synchronized 的锁对象关联了一个 Monitor 对象,Monitor 对象的属性有:owner、waitSet、entryList、recursions。

一个线程访问 synchronized 代码块,尝试占有锁对象关联的 Monitor 对象,操作系统需要从用户态切换到内核态,性能消耗大。

如果这个线程成功占有了 Monitor 对象,则 Monitor 对象的 owner 指向占有它的线程。

其他争抢失败的线程进入 entryList,进入 blocked 状态。

锁对象调用 wait 方法,释放锁,线程进入 waitSet,进入 waiting 或者 timed_waiting 状态。

锁对象调用 notify 方法,从 waitSet 中随机唤醒一个线程;notifyAll 方法唤醒 waitSet 中的所有线程。被唤醒的线程需要重新争抢锁对象。

如果有锁重入情况,让 recursions++ 表示一次锁重入。

synchronized 的加锁过程很复杂吧,别急,后面还有更复杂的。

synchronized 有偏向锁、轻量级锁和重量级锁的说法:

  • java 在 JDK6 的时候引入了偏向锁,在 JDK15 的时候废弃了偏向锁,不过我们这里一样会讲解,简单过一下流程。

synchronized 的加锁过程:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。(或者无锁 -> 轻量级锁 -> 重量级锁)

线程第一次进入 synchronized 代码块或者 synchronized 方法,不会占有 Monitor 对象,而是添加偏向锁。

偏向锁就是在锁对象上添加一个标记,标记这个锁已经被自己占有。

如果添加标记成功,则线程获得偏向锁,当线程离开 synchronized 代码块或者 synchronized 方法时,不会释放偏向锁。(偏向锁只上不开)

如果锁对象已经添加了自己的标记,就是一次锁重入。

如果添加标记失败,或者锁对象上面是别的线程的标记,说明运行环境里不只有一个线程,偏向锁会升级成轻量级锁。

在全局安全点,也就是不执行任何字节码的时刻(STW),jvm 会检查拥有偏向锁的线程是否存活。如果该线程仍然存活且仍然没有从 synchronized 代码块或者 synchronized 方法中出来,则偏向锁升级成轻量级锁,由原本持有偏向锁的线程继续持有轻量级锁。

如果拥有偏向锁的线程已经出来了或者已经结束,则其他线程可以争抢轻量级锁。

轻量级锁之所以叫轻量级锁,是因为占有轻量级锁不需要操作系统从用户态切换成内核态,比重量级锁性能高。

我们不需要关系轻量级锁具体是怎么实现的,只需要知道,争抢轻量级锁只有两种结果:争抢成功,争抢失败。

争抢成功没话说,直接拥有锁对象。争抢失败的话,线程会进行自旋。比如说每隔 50ms 就看一下锁有没有释放,如果释放了继续争抢,如果失败了或者锁没有释放就继续自旋。如果自旋过程中获得锁,这个锁还是轻量级锁。

但是,如果自旋次数达到上限,还是没有获得锁,轻量级锁会升级成重量级锁。

我们还能不能、能不能再见面,我在佛前苦苦求了几千年

  • 偏向锁:没有线程竞争,只需要标记一下这个锁被哪个线程占用即可,不需要加重量级锁

  • 轻量级锁:低并发场景,锁的使用效率高,通过自旋可以一直保持轻量级锁

  • 重量级锁:高并发场景下,最终都会升级成重量级锁

锁只能升级,不能降级。

2.ReentrantLock

ReentrantLock 的使用方式和 synchronized 差不多。

synchronized (锁对象) {
​
}
static ReentrantLock lock = new ReentrantLock();
lock.lock();
// ...
lock.unlock();

一个 lock 对应一个 unlock,如果不释放锁可能导致死锁。

lock() 就是 synchronized 的左括号,unlock() 就是 synchronized 的右括号,而 ReentrantLock 对象就是锁对象,所以 ReentrantLock 对象必须是唯一的。

它们两个用起来差不多,那有了 synchronized,为什么还需要 ReentrantLock 呢?

synchronized 是 jvm 实现的,添加锁和释放锁不能手动控制,使用起来没有 ReentrantLock 灵活。

ReentrantLock 除了可以手动添加锁和释放锁,还灵活在哪呢?

首先,两者都支持锁重入。

synchronized 是非公平锁,ReentrantLock 支持公平锁和非公平锁。这里的公平和非公平其实就是先来后到,A 线程先来的,B 线程后来的,锁释放之后应该轮到 A 获得锁,而不是 B 获得锁。

在 new ReentrantLock() 的时候决定是这个锁是公平锁还是非公平锁。

    public ReentrantLock() {
        sync = new NonfairSync();
    }
​
    public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }

而且,在 synchronized 中,一旦争抢锁失败,就会进入 entryList 中等待锁释放,线程进入阻塞状态。

但是 ReentrantLock 支持争抢失败不进入阻塞队列。

  • boolean tryLock():立即返回成功还是失败,失败也不会阻塞

  • boolean tryLock(long, TimeUnit):在指定时间内尝试获得锁

  • void lock():和 synchronized 一样,争抢失败则进入阻塞队列

  • void lockInterruptibly():争抢失败进入阻塞队列,可以响应打断动作,退出阻塞状态;但如果争抢锁成功,则被打断时只会设置打断标志为 true,不抛异常

    public static void main(String[] args) throws InterruptedException {
        ReentrantLock lock = new ReentrantLock();
        int i = 0;
        new Thread(() -> {
            lock.lock();
            while (true) {
                if (i != 0)
                    break;
            }
            lock.unlock();
        }).start();
​
        Thread t = new Thread(() -> {
            try {
                lock.lockInterruptibly();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            lock.unlock();
        });
        t.start();
​
        System.out.println(t.getState());
        t.interrupt();
        Thread.sleep(1000);
        // TERMINATED:抛异常导致线程中断
        System.out.println(t.getState());
    }

除此之外,synchronized 只支持一个条件变量,就是锁对象本身。

ReentrantLock 可通过 newCondition() 获得多个条件变量。

    public static void main(String[] args) throws InterruptedException {
        ReentrantLock lock = new ReentrantLock();
        Condition condition1 = lock.newCondition();
        Condition condition2 = lock.newCondition();
        // 获取锁对象后才能调用,否则抛 IllegalMonitorStateException
        condition1.await();
        condition1.signal();
        condition1.signalAll();
    }

await、signal、signalAll 其实就是 wait、notify、notifyAll。

线程池

线程池的设计思想:

不要每次需要线程的时候就创建一个线程,用完就销毁,下一次要用的时候再创建再销毁。而是用完线程之后不销毁线程,这样下一次再需要线程的时候就可以直接复用线程了,不需要重复创建和销毁线程,减少性能损失。

    public static void main(String[] args) {
        ThreadPoolExecutor pool = new ThreadPoolExecutor(
                3,
                6,
                60,
                TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(3),
                Executors.defaultThreadFactory(),
                new ThreadPoolExecutor.AbortPolicy());
​
        // execute(Runnable)
        // submit(Runnable), Future submit(Callable)
        pool.submit(new Thread(() -> {
            System.out.println();
        }));
​
        pool.shutdown();
    }

ThreadPoolExecutor 最完整的构造方法参数如下:

  • 核心线程

  • 总线程,临时线程 = 总线程 - 核心线程

  • 空闲时间:值

  • 空闲时间:单位

  • 阻塞队列,用来保存任务

  • 线程工厂,用来创建线程

  • 拒绝策略,用来处理过多的任务

线程池的工作过程:

  • 线程池一开始是空的,当有任务提交过来的时候,创建一个核心线程处理任务,处理完之后把线程放回线程池。

  • 如果提交过来的任务数 > 核心线程数,则多出的任务会保存到阻塞队列中。

  • 如果接下来还有任务提交过来,连阻塞队列都放不下了,就创建临时线程,让临时线程执行任务。

  • 当临时线程空闲时间达到指定值,就销毁临时线程,临时线程是应急措施,一般不使用。(如果公司有很多工作,实在完不成了就招实习生进来打杂,打完杂了不忙了就把实习生开掉,迫不得已才招实习生,毕竟实习生要发工资的)

阻塞队列:

  • ArrayBlockingQueue:基于数组实现,必须指定队列长度

  • LinkedBlockingQueue:基于链表实现,不指定长度则默认长度 = Integer.MAX_VALUE

拒绝策略,从 new ThreadPoolExecutor.AbortPolicy() 可以看出来,拒绝策略是 ThreadPoolExecutor 的内部类:

  • AbortPolicy:丢弃提交过来的任务,抛 RejectedExecutionException

  • DiscardPolicy:丢弃提交过来的任务,不抛异常

  • DiscardOldestPolicy:丢弃阻塞队列中最老的任务,将新任务加入队列,队列是先进先出的嘛

  • CallerRunsPolicy:让提交任务的线程自己处理任务

其实还可以快捷创建线程池:

    public static void main(String[] args) {
        ExecutorService pool = Executors.newCachedThreadPool();
​
        pool.submit(() -> {
            System.out.println(111);
        });
        pool.shutdown();
    }

但是阿里巴巴的开发规范中说不允许使用 Executors 创建线程池,这是为什么呢?

主要是 Executors 创建的线程池不安全,要么阻塞队列用的是 LinkedBlockingQueue,不指定队列长度,要么是临时线程数 = Integer.MAX_VALUE。

在大量任务的情况下,阻塞队列太长、临时线程太多会 OOM 的。

有没有想过一个问题:如果线程池里面的线程在处理任务的时候,把线程池关闭了会怎么样?

这个问题非常有趣啊,也比较复杂。

首先我们要先来看线程池的状态:

                   |-----------SHUTDOWN-----------| 
                   |                              |
RUNNING------------|                              |---------TIDYING--------TERMINATED
                   |                              |
                   |-------------STOP-------------|

这里主要看三个状态就可以了:

  • RUNNING:线程池中的线程正常工作,阻塞队列正常保存任务,可以正常接收任务、按照拒绝策略处理过多的任务

  • SHUTDOWN:不再接收新任务,打断空闲的线程,非空闲的线程把剩下的任务处理完再关闭线程池

  • STOP:不再接收任务,清空阻塞队列中的任务,打断正在工作的线程,也就是直接关闭线程池,这些任务都不做了

这个是 ThreadPoolExecutor 里面的状态:

private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
private static final int COUNT_BITS = Integer.SIZE - 3;
​
private static final int RUNNING    = -1 << COUNT_BITS;  // -536870912
private static final int SHUTDOWN   =  0 << COUNT_BITS;  // 0
private static final int STOP       =  1 << COUNT_BITS;  // 536870912
private static final int TIDYING    =  2 << COUNT_BITS;  // 1073741824
private static final int TERMINATED =  3 << COUNT_BITS;  // 1610612736

关闭线程池有两个方法:shutdown() 和 shutdownNow()。

shutdown() 关闭线程池:

  • 线程池从 RUNNING -> SHUTDOWN

  • 提交任务时,如果是 RUNNGING 状态,可能被拒绝,如果是 SHUTDOWN,百分百拒绝,按照拒绝策略拒绝的,不一定抛异常。

  • 线程池里面的线程有些可能在干活,有些可能不在干活,空闲的线程被打断销毁,忙碌的线程留下来把活干完。

  • shutdown() 的返回值是 void

shutdownNow() 关闭线程池就非常粗暴了:

  • RUNNING -> STOP

  • 打断并销毁所有线程

  • 清空阻塞队列

  • 提交任务根据拒绝策略处理

  • shutdownNow() 的返回值是 List<Runnable>,也就是把阻塞队列中的任务返回给你,你可以决定怎么处理这些任务

虚拟线程

JDK21 带来了新特性:虚拟线程,这是处理 IO 阻塞型任务的利器。

那什么是虚拟线程呢?这就要说回 java 的线程类型了。

在没有虚拟线程之前,java 的线程是平台线程。

那什么又是平台线程呢?

在 java 中创建一个线程,其实就是在操作系统中创建一个线程,然后 java 对这个线程做一些包装就变成了平台线程。简单理解就是:平台线程 = 操作系统线程。

这种平台线程有一些缺点啊。

  • 因为平台线程是一个操作系统层面的线程,那创建平台线程、销毁平台线程都要经过操作系统,操作系统需要在用户态和内核态切换,这个开销是很大的

  • 操作系统的线程的内存大小是固定的,在 java 中创建大量的平台线程会消耗珍贵的内存空间

  • 多线程环境下切换线程需要保存线程上下文,这个操作是必不可少的,同时也是比较消耗性能的。而线程越多,调度器需要在更多的线程之间做决策,时间片轮转或者抢占式调度会更频繁,消耗更多性能

  • 当平台线程执行 IO 操作时阻塞住了,比如进行网络调用,那这个线程就要等这个接口返回,如果这个接口的延迟是 50ms,平台线程就要等 50ms 再工作。

    • 就算响应返回了数据,但是将数据从网卡中读入内存,这个过程的主力也不是 CPU

    • 这比较伤啊,因为线程获得了 CPU 但是没有充分使用 CPU 资源,CPU 大部分时间是空闲的

    • 我说白了,CPU,你要是利用平时空闲的一半时间去学习,早就考上清华北大了

那虚拟线程有哪些优点呢:

  • 虚拟线程不是平台线程,是 java 自己管理的线程,也就是说:创建一个虚拟线程,不会创建一个实际存在的操作系统线程。或者,可以理解成创建一个虚拟线程 = new 一个对象。相对于在操作系统中切换用户态和内核态,是很轻的操作了。

  • 虚拟线程是被 java 自己管理的,所以线程占用的内存大小是可调整的,一开始可以分配非常少的内存让线程活跃起来。跟平台线程比起来,可以在有限的资源内创建更多的虚拟线程。

  • 虚拟线程也是线程,所以切换线程的时候也要保存线程上下文,大量虚拟线程的情况下频繁切换的性能确实更差,但是虚拟线程是 java 管理的啊,也是由 java 进行调度的。切换虚拟线程是在 java 中进行的,对操作系统无感。

  • 当一个虚拟线程阻塞了,java 会直接换掉这个虚拟线程,调度其他的虚拟线程来工作,充分利用 CPU 的性能。

你讲了这么多,我没怎么听懂。这个虚拟线程到底是怎么工作的?什么叫被 java 管理?被 java 调度?

首先还是先来看平台线程。平台线程是一个操作系统线程嘛,所以平台线程要工作,就要争抢 CPU 的使用权,平台线程的切换是由操作系统调度的。

而虚拟线程自己不能干活,要挂载到一个具体的平台线程上面才能干活,当然啦,这个平台线程也要争抢到 CPU 才能干活嘛。

这,儿子像爸爸?还是爸爸像儿子?

别急,当平台线程阻塞的时候,CPU 是空闲的对不对。

如果是传统的线程,CPU 如果没有切换其他线程,这个平台线程一直占用 CPU 不干活。

但是在虚拟线程中,A 虚拟线程挂载到平台线程上面干活,如果 A 虚拟线程阻塞了,平台线程还在占用 CPU。而虚拟线程是被 java 调度的,java 会切换其他虚拟线程干活。也就是让 A 虚拟线程让出这个平台线程,让 B 虚拟线程挂载到这个平台线程上干活。这样的话,这个平台线程获得了 CPU,即使中间阻塞了,也可以切换其他虚拟线程,让 CPU 一直工作。

如果 A 虚拟线程阻塞结束了,就又可以被调度,挂载到平台线程上干活了。

这不是流水线吗?一个工人负责一条流水线,性能过剩,然后让一个工人负责多条流水线,充分压榨工人的劳动力。

意思差不多,之前是一个平台线程干活,它偷懒也没办法,现在有多个人轮流干活,保证一直有人在干活。

下面就要学习虚拟线程的语法了。

    public static void main(String[] args) {
        // Thread.startVirtualThread(Runnable)
        Thread.startVirtualThread(() -> {
            System.out.println("虚拟线程play");
        });
        
        // Thread.ofVirtual + Builder模式
        Thread.ofVirtual()
                .name("t1")
                .start(()->{
                    System.out.println("这是死磕 Java 新特性的文章—Java 19 新特性:虚拟线程");
                });
    }

java 推出了虚拟线程,但不是用来取代传统线程的,这两种线程有不同的写法。如果你要用虚拟线程,就用新写法,如果要用传统线程,就用传统写法。

当然啦,线程池肯定是不能缺少的。

    public static void main(String[] args) {
        ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor();
        // submit的参数有Callable和Runnable
        pool.submit(...);
        // execute只有Runnable类型参数
        pool.execute(...);
    }

不是说不能用 Executors 创建线程池吗?

那是传统线程,因为传统线程会对应一个操作系统的线程啊,开销很大。

而虚拟线程创建成本和管理成本很低,官方的建议是用的时候 new,用完就丢,所以这里可以用 Executors 创建线程池。通过 newVirtualThreadPerTaskExecutor 创建线程池,每提交一个任务就新建一个虚拟线程,线程不限量。

当然啦,如果虚拟线程太多也是会 OOM 的。DeepSeek 说一百万个虚拟线程占用的空间在 1 - 4GB 左右,取决于任务的复杂程度。但是 2GB 内存也是可以写 java 的嘛,所以如果只有 2GB 内存就会 OOM。

不对啊,如果传统线程和虚拟线程可以同时存在,那虚拟线程要挂载到平台线程上才能干活。如果现在有 10 个传统线程,虚拟线程把 5 个传统线程抢走了,只剩下 5 个传统线程在干 10 个人的活,不是影响了传统线程的性能吗?

虚拟线程确实要挂载到平台线程上,但这个平台线程不是从其他地方抢过来的,这个平台线程叫做载体线程。

载体线程是 java 提供的,来自 jvm 内部的 ForkJoinPool,数量默认 = Runtime.getRuntime().availableProcessors(),数量默认 = CPU 的总线程数。因为现在的 CPU 基本都是超线程的,8 核心 16 线程,Runtime.getRuntime().availableProcessors() 返回的就是 16。CPU 的总线程数是 16,你的载体线程超过 16 就没有意义了啊,最多也就同时执行 16 个任务。

传统线程的平台线程和载体线程的平台线程是隔离的,并不是用传统线程充当载体线程,不会影响传统线程的性能。

传统线程:不相干。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐