ThreadLocal的使用

既然每个线程都有一个自己的工作内存,那么能否只在自己的工作内存中创建变量仅供线程自己使用呢?

img

我们可以使用ThreadLocal类,来创建工作内存中的变量,它将我们的变量值存储在内部(只能存储一个变量),不同的线程访问到ThreadLocal对象时,都只能获取到当前线程所属的变量。

java 复制代码
public static void main(String[] args) throws InterruptedException {
    ThreadLocal<String> local = new ThreadLocal<>();  //注意这是一个泛型类,存储类型为我们要存放的变量类型
    Thread t1 = new Thread(() -> {
        local.set("lbwnb");   //将变量的值给予ThreadLocal
        System.out.println("变量值已设定!");
        System.out.println(local.get());   //尝试获取ThreadLocal中存放的变量
    });
    Thread t2 = new Thread(() -> {
        System.out.println(local.get());   //尝试获取ThreadLocal中存放的变量
    });
    t1.start();
    Thread.sleep(3000);    //间隔三秒
    t2.start();
}

上面的例子中,我们开启两个线程分别去访问ThreadLocal对象,我们发现,第一个线程存放的内容,第一个线程可以获取,但是第二个线程无法获取,我们再来看看第一个线程存入后,第二个线程也存放,是否会覆盖第一个线程存放的内容:

java 复制代码
public static void main(String[] args) throws InterruptedException {
    ThreadLocal<String> local = new ThreadLocal<>();  //注意这是一个泛型类,存储类型为我们要存放的变量类型
    Thread t1 = new Thread(() -> {
        local.set("lbwnb");   //将变量的值给予ThreadLocal
        System.out.println("线程1变量值已设定!");
        try {
            Thread.sleep(2000);    //间隔2秒
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("线程1读取变量值:");
        System.out.println(local.get());   //尝试获取ThreadLocal中存放的变量
    });
    Thread t2 = new Thread(() -> {
        local.set("yyds");   //将变量的值给予ThreadLocal
        System.out.println("线程2变量值已设定!");
    });
    t1.start();
    Thread.sleep(1000);    //间隔1秒
    t2.start();
}

我们发现,即使线程2重新设定了值,也没有影响到线程1存放的值,所以说,不同线程向ThreadLocal存放数据,只会存放在线程自己的工作空间中,而不会直接存放到主内存中,因此各个线程直接存放的内容互不干扰。

我们发现在线程中创建的子线程,无法获得父线程工作内存中的变量:

java 复制代码
public static void main(String[] args) {
    ThreadLocal<String> local = new ThreadLocal<>();
    Thread t = new Thread(() -> {
       local.set("lbwnb");
        new Thread(() -> {
            System.out.println(local.get());
        }).start();
    });
    t.start();
}

我们可以使用InheritableThreadLocal来解决:

java 复制代码
public static void main(String[] args) {
    ThreadLocal<String> local = new InheritableThreadLocal<>();
    Thread t = new Thread(() -> {
       local.set("lbwnb");
        new Thread(() -> {
            System.out.println(local.get());
        }).start();
    });
    t.start();
}

在InheritableThreadLocal存放的内容,会自动向子线程传递。

定时器

我们有时候会有这样的需求,我希望定时执行任务,比如3秒后执行,其实我们可以通过使用Thread.sleep()来实现:

java 复制代码
public static void main(String[] args) {
    new TimerTask(() -> System.out.println("我是定时任务!"), 3000).start();   //创建并启动此定时任务
}

static class TimerTask{
    Runnable task;
    long time;

    public TimerTask(Runnable runnable, long time){
        this.task = runnable;
        this.time = time;
    }

    public void start(){
        new Thread(() -> {
            try {
                Thread.sleep(time);
                task.run();   //休眠后再运行
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
    }
}

我们通过自行封装一个TimerTask类,并在启动时,先休眠3秒钟,再执行我们传入的内容。那么现在我们希望,能否循环执行一个任务呢?比如我希望每隔1秒钟执行一次代码,这样该怎么做呢?

java 复制代码
public static void main(String[] args) {
    new TimerLoopTask(() -> System.out.println("我是定时任务!"), 3000).start();   //创建并启动此定时任务
}

static class TimerLoopTask{
    Runnable task;
    long loopTime;

    public TimerLoopTask(Runnable runnable, long loopTime){
        this.task = runnable;
        this.loopTime = loopTime;
    }

    public void start(){
        new Thread(() -> {
            try {
                while (true){   //无限循环执行
                    Thread.sleep(loopTime);
                    task.run();   //休眠后再运行
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
    }
}

现在我们将单次执行放入到一个无限循环中,这样就能一直执行了,并且按照我们的间隔时间进行。

但是终究是我们自己实现,可能很多方面还没考虑到,Java也为我们提供了一套自己的框架用于处理定时任务:

java 复制代码
public static void main(String[] args) {
    Timer timer = new Timer();    //创建定时器对象
    timer.schedule(new TimerTask() {   //注意这个是一个抽象类,不是接口,无法使用lambda表达式简化,只能使用匿名内部类
        @Override
        public void run() {
            System.out.println(Thread.currentThread().getName());    //打印当前线程名称
        }
    }, 1000);    //执行一个延时任务
}

我们可以通过创建一个Timer类来让它进行定时任务调度,我们可以通过此对象来创建任意类型的定时任务,包延时任务、循环定时任务等。我们发现,虽然任务执行完成了,但是我们的程序并没有停止,这是因为Timer内存维护了一个任务队列和一个工作线程:

java 复制代码
public class Timer {
    /**
     * The timer task queue.  This data structure is shared with the timer
     * thread.  The timer produces tasks, via its various schedule calls,
     * and the timer thread consumes, executing timer tasks as appropriate,
     * and removing them from the queue when they're obsolete.
     */
    private final TaskQueue queue = new TaskQueue();

    /**
     * The timer thread.
     */
    private final TimerThread thread = new TimerThread(queue);
  
		...
}

TimerThread继承自Thread,是一个新创建的线程,在构造时自动启动:

java 复制代码
public Timer(String name) {
    thread.setName(name);
    thread.start();
}

而它的run方法会循环地读取队列中是否还有任务,如果有任务依次执行,没有的话就暂时处于休眠状态:

java 复制代码
public void run() {
    try {
        mainLoop();
    } finally {
        // Someone killed this Thread, behave as if Timer cancelled
        synchronized(queue) {
            newTasksMayBeScheduled = false;
            queue.clear();  // Eliminate obsolete references
        }
    }
}

/**
 * The main timer loop.  (See class comment.)
 */
private void mainLoop() {
  try {
       TimerTask task;
       boolean taskFired;
       synchronized(queue) {
         	// Wait for queue to become non-empty
          while (queue.isEmpty() && newTasksMayBeScheduled)   //当队列为空同时没有被关闭时,会调用wait()方法暂时处于等待状态,当有新的任务时,会被唤醒。
                queue.wait();
          if (queue.isEmpty())
             break;    //当被唤醒后都没有任务时,就会结束循环,也就是结束工作线程
                      ...
}

newTasksMayBeScheduled实际上就是标记当前定时器是否关闭,当它为false时,表示已经不会再有新的任务到来,也就是关闭,我们可以通过调用cancel()方法来关闭它的工作线程:

java 复制代码
public void cancel() {
    synchronized(queue) {
        thread.newTasksMayBeScheduled = false;
        queue.clear();
        queue.notify();  //唤醒wait使得工作线程结束
    }
}

因此,我们可以在使用完成后,调用Timer的cancel()方法以正常退出我们的程序:

java 复制代码
public static void main(String[] args) {
    Timer timer = new Timer();
    timer.schedule(new TimerTask() {
        @Override
        public void run() {
            System.out.println(Thread.currentThread().getName());
            timer.cancel();  //结束
        }
    }, 1000);
}

守护线程

注意: 不要把操作系统中的守护进程和守护线程相提并论。

操作系统中的守护进程在后台运行,不需要和用户交互,本质和普通进程类似。而守护线程就不一样了,当其他所有的非守护线程结束之后,守护线程自动结束,也就是说,Java中所有的线程都执行完毕后,守护线程自动停止,因此守护线程不适合进行IO操作,只适合打打杂:

java 复制代码
public static void main(String[] args) throws InterruptedException{
    Thread t = new Thread(() -> {
        while (true){
            try {
                System.out.println("程序正常运行中...");
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    });
    t.setDaemon(true);   //设置为守护线程(必须在开始之前,中途是不允许转换的)
    t.start();
    for (int i = 0; i < 5; i++) {
        Thread.sleep(1000);
    }
}

在守护线程中产生的新线程也是守护的:

java 复制代码
public static void main(String[] args) throws InterruptedException{
    Thread t = new Thread(() -> {
        Thread it = new Thread(() -> {
            while (true){
                try {
                    System.out.println("程序正常运行中...");
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        it.start();
    });
    t.setDaemon(true);   //设置为守护线程(必须在开始之前,中途是不允许转换的)
    t.start();
    for (int i = 0; i < 5; i++) {
        Thread.sleep(1000);
    }
}

再谈集合类

因为多线程的加入,我们之前认识的集合类都废掉了:

java 复制代码
public static void main(String[] args) throws InterruptedException {
    List<Integer> list = new ArrayList<>();
    new Thread(() -> {
        for (int i = 0; i < 1000; i++) {
            list.add(i);   //两个线程同时操作集合类进行插入操作
        }
    }).start();
    new Thread(() -> {
        for (int i = 1000; i < 2000; i++) {
            list.add(i);
        }
    }).start();
    Thread.sleep(2000);
    System.out.println(list.size());
}

我们发现,有些时候运气不好,得到的结果并不是2000个元素,而是:

image-20221004212332535

因为之前的集合类,并没有考虑到多线程运行的情况,如果两个线程同时执行,那么有可能两个线程同一时间都执行同一个方法,这种情况下就很容易出问题:

java 复制代码
public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // 当数组容量更好还差一个满的时候,这个时候两个线程同时走到了这里,因为都判断为没满,所以说没有进行扩容,但是实际上两个线程都要插入一个元素进来
    elementData[size++] = e;   //当两个线程同时在这里插入元素,直接导致越界访问
    return true;
}

当然,在Java早期的时候,还有一些老的集合类,这些集合类都是线程安全的:

java 复制代码
public static void main(String[] args) throws InterruptedException {
    Vector<Integer> list = new Vector<>();   //我们可以使用Vector代替List使用
  	//Hashtable<Integer, String>   也可以使用Hashtable来代替Map
    new Thread(() -> {
        for (int i = 0; i < 1000; i++) {
            list.add(i);
        }
    }).start();
    new Thread(() -> {
        for (int i = 1000; i < 2000; i++) {
            list.add(i);
        }
    }).start();

    Thread.sleep(1000);
    System.out.println(list.size());
}

因为这些集合类中的每一个方法都加了锁,所以说不会出现多线程问题,但是这些老的集合类现在已经不再使用了,我们会在JUC篇视频教程中介绍专用于并发编程的集合类。

通过对Java多线程的了解,我们就具备了利用多线程解决问题的思维!

(Java 8) 并行流

集合类中有一个东西是Java8新增的Spliterator接口,翻译过来就是:可拆分迭代器(Splitable Iterator)和Iterator一样,Spliterator也用于遍历数据源中的元素,但它是为了并行执行而设计的。Java 8已经为集合框架中包含的所有数据结构提供了一个默认的Spliterator实现。在集合跟接口Collection中提供了一个spliterator()方法用于获取可拆分迭代器。

其实我们之前在讲解集合类的根接口时,就发现有这样一个方法:

java 复制代码
default Stream<E> parallelStream() {
    return StreamSupport.stream(spliterator(), true); //parallelStream就是利用了可拆分迭代器进行多线程操作
}

并行流,其实就是一个多线程执行的流,它通过默认的ForkJoinPool实现(这里不讲解原理),它可以提高你的多线程任务的速度。

java 复制代码
public static void main(String[] args) {
    List<Integer> list = new ArrayList<>(Arrays.asList(1, 4, 5, 2, 9, 3, 6, 0));
    list
            .parallelStream()    //获得并行流
            .forEach(i -> System.out.println(Thread.currentThread().getName()+" -> "+i));
}

我们发现,forEach操作的顺序,并不是我们实际List中的顺序,同时每次打印也是不同的线程在执行!我们可以通过调用forEachOrdered()方法来使用单线程维持原本的顺序:

java 复制代码
public static void main(String[] args) {
    List<Integer> list = new ArrayList<>(Arrays.asList(1, 4, 5, 2, 9, 3, 6, 0));
    list
            .parallelStream()    //获得并行流
            .forEachOrdered(System.out::println);
}

我们之前还发现,在Arrays数组工具类中,也包含大量的并行方法:

java 复制代码
public static void main(String[] args) {
    int[] arr = new int[]{1, 4, 5, 2, 9, 3, 6, 0};
    Arrays.parallelSort(arr);   //使用多线程进行并行排序,效率更高
    System.out.println(Arrays.toString(arr));
}

更多地使用并行方法,可以更加充分地发挥现代计算机多核心的优势,但是同时需要注意多线程产生的异步问题。

java 复制代码
public static void main(String[] args) {
    int[] arr = new int[]{1, 4, 5, 2, 9, 3, 6, 0};
    Arrays.parallelSetAll(arr, i -> {
        System.out.println(Thread.currentThread().getName());
        return arr[i];
    });
    System.out.println(Arrays.toString(arr));
}

实战:生产者与消费者

所谓的生产者消费者模型,是通过一个容器来解决生产者和消费者的强耦合问题。通俗的讲,就是生产者在不断的生产,消费者也在不断的消费,可是消费者消费的产品是生产者生产的,这就必然存在一个中间容器,我们可以把这个容器想象成是一个货架,当货架空的时候,生产者要生产产品,此时消费者在等待生产者往货架上生产产品,而当货架有货物的时候,消费者可以从货架上拿走商品,生产者此时等待货架出现空位,进而补货,这样不断的循环。

通过多线程编程,来模拟一个餐厅的2个厨师和3个顾客,假设厨师炒出一个菜的时间为3秒,顾客吃掉菜品的时间为4秒。

(Java 21) 线程生成器

Java 21为我们新增了一个ofPlatform()方法,它可以快速生成一个创建平台线程(就是我们上面介绍的线程,下面还会介绍虚拟线程)的线程生成器:

java 复制代码
Thread.ofPlatform()
        .name("线程01")   //直接调用方法设置线程的相关属性
        .daemon(true)   //是否为守护线程
        .priority(Thread.MAX_PRIORITY)  //设置优先级
        .inheritInheritableThreadLocals(false)  //是否允许InheritableThreadLocal继承数据
        .start(() -> {    //配置好后一键启动
            System.out.println("Hello World");
        });

使用此线程生成器帮我们创建线程来的更快更直接一点。

对于线程内发生的异常,我们还可以为其指定异常处理器,通过调用uncaughtExceptionHandler方法来指定:

java 复制代码
Thread.ofPlatform()
        .uncaughtExceptionHandler((t, exception) -> {
            System.out.println("检测到线程执行中出现异常: " + exception);
        })
        .start(() -> {
           throw new RuntimeException("我出事了!");
        });

此外,我们还可以为线程设定其堆栈大小。这个堆栈是什么意思呢,由于现在各位小伙伴还没有学习JVM底层原理,这里就简单给大家解释一下。假设现在我们执行这样的代码:

java 复制代码
public static void main(String[] args) {
    test1();
}

private static void test1() {
    test2();
}

private static void test2() {
    System.out.println("我是最里面");
}

可以看到,我们在main方法内调用test1方法,然后test1内又调用test2方法,在方法的调用过程中,实际上有一个专门的栈结构,在储存方法的调用和其参数。我们每向下调用一次方法,那么方法信息以及其携带的参数就会进栈,像这样的东西我们一般称为"栈帧",然后等待位于最顶上的方法执行完毕:

image-20250709174657728

当最顶上的方法结束之后,栈中的对应的栈帧便会离开,从而继续执行下一个位于栈顶的方法:

image-20250709175203003

大致原理就是这样,程序的执行需要用到栈来维护秩序。不过栈的深度是有限制的,就像我们之前使用递归的形式调用方法,如果不加以限制,那么栈中将无限制插入栈帧,这肯定会出现问题。默认情况下,栈的深度由JVM自动设定,我们也可以手动干预每个线程的最大栈深度:

java 复制代码
public static void main(String[] args) {
    Thread.ofPlatform()
            .start(Main::test1);
}

public static int i = 0;
private static void test1() {  //写个递归无限往下
    System.out.println(i++);  //打印i来查看调用深度
    test1();
}
image-20250709180135817

默认情况下,在深度达到30000左右时出现了爆栈的情况(不同的系统可能结果不一样)我们可以通过配置stackSize来建议此线程使用多大的栈空间:

java 复制代码
public static void main(String[] args) {
    Thread.ofPlatform()
            .stackSize(1024 * 1024 * 10)  //空间大小,单位为Byte,这里给了10MB
            .start(Main::test1);
}

重新执行后,可以看到,调用深度比刚刚大了不少:

image-20250709180451994

不过这个参数只是建议,如果给的过大或者过小都会强制使用预设的最大最小值。

(Java 21) 虚拟线程

虚拟线程(Virtual Threads)是Java在JDK 19中引入的一项新特性,旨在简化并发编程模型,提升高并发应用的性能和可扩展性,此功能最终在Java 21版本实装上线。

虚拟线程是Java中的一种轻量级线程,由Java虚拟机(JVM)管理,与我们之前介绍的传统的操作系统级线程(Platform Threads)相比,它免去了平台线程的CPU的上下文切换,而是由程序在线程内自行控制,消耗的资源更少,启动和切换速度更快,进而可以在同一物理线程上并发执行大量虚拟线程。其核心优势为:

  • 高并发能力:数以万计的虚拟线程可以在少量的操作系统线程上并发执行,极大提升应用的并发能力。
  • 简化编程模型:开发者可以使用同步的阻塞式代码编写异步或高并发逻辑,无需手动管理复杂的回调或异步框架。
  • 资源效率:虚拟线程内存占用较少,启动速度快,有助于构建高性能的服务器和微服务。

综上,虚拟线程实际上是在单个线程内进一步细分的产物:

image-20250709155427062

线程在Java中分为两种:

  • 平台线程: 就是我们上面讲解的线程,由操作系统进行调度。
  • 虚拟线程: 本节介绍的由JVM在线程内部调度的线程。

创建虚拟线程非常简单,直接使用startVirtualThread方法即可,它和线程的用法是一样的:

java 复制代码
Thread.startVirtualThread(() -> {
    System.out.println("我是虚拟线程");
});

注意,虚拟线程和平台线程不一样,它默认就是守护线程且不能修改,也就是说主线程结束它也会跟着结束,所以上面的代码我们执行之后不会出现任何内容。我们需要使用join在主线程等待其完成:

java 复制代码
Thread.startVirtualThread(() -> {
    System.out.println("我是虚拟线程");
}).join();
image-20250709181308353

除此之外,我们也可以使用生成器创建虚拟线程:

java 复制代码
Thread.ofVirtual()
        .name("虚拟线程 01")
        .start(() -> System.out.println("我真的是虚拟线程"));

注意,虚拟线程不支持使用stop结束线程,调用会报错。有关虚拟线程的实现原理和更多用法,我们会在JUC篇和Java学习路线的后续课程中逐步介绍。

反射

注意: 本章节涉及到JVM相关底层原理,难度会有一些大。

反射就是把Java类中的各个成分映射成一个个的Java对象。即在运行状态中,对于任意一个类,都能够知道这个类所有的属性和方法,对于任意一个对象,都能调用它的任意一个方法和属性。这种动态获取信息及动态调用对象方法的功能叫Java的反射机制。

简而言之,我们可以通过反射机制,获取到类的一些属性,包括类里面有哪些字段,有哪些方法,继承自哪个类,甚至还能获取到泛型!它的权限非常高,慎重使用!

Java类加载机制

在学习Java的反射机制之前,我们需要先了解一下类的加载机制,一个类是如何被加载和使用的:

image-20221004213335479

在Java程序启动时,JVM会将一部分类(class文件)先加载(并不是所有的类都会在一开始加载),通过ClassLoader将类加载,在加载过程中,会将类的信息提取出来(存放在元空间中,JDK1.8之前存放在永久代),同时也会生成一个Class对象存放在内存(堆内存),注意此Class对象只会存在一个,与加载的类唯一对应!

为了方便各位小伙伴理解,你们就直接理解为默认情况下(仅使用默认类加载器)每个类都有且只有一个唯一的Class对象存放在JVM中,我们无论通过什么方式访问,都是始终是那一个对象。Class对象中包含我们类的一些信息,包括类里面有哪些方法、哪些变量等等。

Class类获取

通过前面,我们了解了类的加载,同时会提取一个类的信息生成Class对象存放在内存中,而反射机制其实就是利用这些存放的类信息,来获取类的信息和操作类。那么如何获取到每个类对应的Class对象呢,我们可以通过以下方式:

java 复制代码
public static void main(String[] args) throws ClassNotFoundException {
    Class<String> clazz = String.class;   //使用class关键字,通过类名获取
    Class<?> clazz2 = Class.forName("java.lang.String");   //使用Class类静态方法forName(),通过包名.类名获取,注意返回值是Class<?>
    Class<?> clazz3 = new String("cpdd").getClass();  //通过实例对象获取
}

注意Class类也是一个泛型类,只有第一种方法,能够直接获取到对应类型的Class对象,而以下两种方法使用了?通配符作为返回值,但是实际上都和第一个返回的是同一个对象:

java 复制代码
Class<String> clazz = String.class;   //使用class关键字,通过类名获取
Class<?> clazz2 = Class.forName("java.lang.String");   //使用Class类静态方法forName(),通过包名.类名获取,注意返回值是Class<?>
Class<?> clazz3 = new String("cpdd").getClass();

System.out.println(clazz == clazz2);
System.out.println(clazz == clazz3);

通过比较,验证了我们一开始的结论,在JVM中每个类始终只存在一个Class对象,无论通过什么方法获取,都是一样的。现在我们再来看看这个问题:

java 复制代码
public static void main(String[] args) {
    Class<?> clazz = int.class;   //基本数据类型有Class对象吗?
    System.out.println(clazz);
}

迷了,不是每个类才有Class对象吗,基本数据类型又不是类,这也行吗?实际上,基本数据类型也有对应的Class对象(反射操作可能需要用到),而且我们不仅可以通过class关键字获取,其实本质上是定义在对应的包装类中的:

java 复制代码
/**
 * The {@code Class} instance representing the primitive type
 * {@code int}.
 *
 * @since   JDK1.1
 */
@SuppressWarnings("unchecked")
public static final Class<Integer>  TYPE = (Class<Integer>) Class.getPrimitiveClass("int");

/*
 * Return the Virtual Machine's Class object for the named
 * primitive type
 */
static native Class<?> getPrimitiveClass(String name);   //C++实现,并非Java定义

每个包装类中(包括Void),都有一个获取原始类型Class方法,注意,getPrimitiveClass获取的是原始类型,并不是包装类型,只是可以使用包装类来表示。

java 复制代码
public static void main(String[] args) {
    Class<?> clazz = int.class;
    System.out.println(Integer.TYPE == int.class);
}

通过对比,我们发现实际上包装类型都有一个TYPE,其实也就是基本类型的Class,那么包装类的Class和基本类的Class一样吗?

java 复制代码
public static void main(String[] args) {
    System.out.println(Integer.TYPE == Integer.class);
}

我们发现,包装类型的Class对象并不是基本类型Class对象。

数组类型也是一种类型,只是编程不可见,因此我们可以直接获取数组的Class对象:

java 复制代码
public static void main(String[] args) {
    Class<String[]> clazz = String[].class;
    System.out.println(clazz.getName());  //获取类名称(得到的是包名+类名的完整名称)
    System.out.println(clazz.getSimpleName());
    System.out.println(clazz.getTypeName());
    System.out.println(clazz.getClassLoader());   //获取它的类加载器
    System.out.println(clazz.cast(new Integer("10")));   //强制类型转换
}

下节课,我们将开始对Class对象的使用进行讲解。

正在加载页面,请稍后...