【Java编程】JavaSE基础总结(六):多线程

【JavaSE】系列包含:


进程是程序执行的实体,每一个进程都是一个应用程序(比如我们运行 QQ、浏览器、LOL、网易云音乐等软件),都有自己的内存空间,CPU 一个核心同时只能处理一件事情,当出现多个进程需要同时运行时,CPU 一般通过 时间片轮转调度算法,来实现多个进程的同时运行。

在早期的计算机中,进程是拥有资源和独立运行的最小单位,也是程序执行的最小单位。但是,如果我希望两个任务同时进行,就必须运行两个进程,由于每个进程都有一个自己的内存空间,进程之间的通信就变得非常麻烦(比如要共享某些数据),而且执行不同进程会产生上下文切换,非常耗时,那么能否实现在一个进程中就能够执行多个任务呢?

后来,线程横空出世,一个进程可以有多个线程,线程是程序执行中一个单一的顺序控制流程,现在线程才是程序执行流的最小单元,各个线程之间共享程序的内存空间(也就是所在进程的内存空间),上下文切换速度也高于进程。

在 Java 中,我们从开始,一直以来编写的都是单线程应用程序(运行 main() 方法的内容),也就是说只能同时执行一个任务(无论你是调用方法、还是进行计算,始终都是依次进行的,也就是同步的),而如果我们希望同时执行多个任务(两个方法同时在运行或者是两个计算同时在进行,也就是异步的),就需要用到 Java 多线程框架。实际上一个 Java 程序启动后,会创建很多线程,不仅仅只运行一个主线程:

public static void main(String[] args) {
    ThreadMXBean bean = ManagementFactory.getThreadMXBean(); // 获取一个ThreadMXBean
    long[] ids = bean.getAllThreadIds();
    ThreadInfo[] infos = bean.getThreadInfo(ids);
    for (ThreadInfo info : infos) {
        System.out.println(info.getThreadName());
    }
}

在这里插入图片描述

ManagementFactory 是一个为我们提供各种获取 JVM 信息的工厂类,使用 ManagementFactory 可以获取大量的运行时 JVM 信息,比如 JVM 堆的使用情况,以及 GC 情况,线程信息等,通过这些数据项我们可以了解正在运行的 JVM 的情况,以便我们可以做出相应的调整。

通过 ThreadMXBean 提供的方法,我们可以获取详细的运行时 JVM 内的线程信息。

关于除了 main 线程默认以外的线程,涉及到 JVM 相关底层原理,在这里不做讲解,了解就行。

1.线程的创建和启动

通过创建 Thread 对象来创建一个新的线程,Thread 构造方法中需要传入一个 Runnable 接口的实现(其实就是编写要在另一个线程执行的内容逻辑)同时 Runnable 只有一个未实现方法,因此可以直接使用 lambda 表达式:

@FunctionalInterface
public interface Runnable {
    /**
     * When an object implementing interface <code>Runnable</code> is used
     * to create a thread, starting the thread causes the object's
     * <code>run</code> method to be called in that separately executing
     * thread.
     * <p>
     * The general contract of the method <code>run</code> is that it may
     * take any action whatsoever.
     *
     * @see     java.lang.Thread#run()
     */
    public abstract void run();
}

创建好后,通过调用 start() 方法来运行此线程:

public static void main(String[] args) {
    Thread t = new Thread(() -> {    //直接编写逻辑
        System.out.println("我是另一个线程!");
    });
    t.start();   //调用此方法来开始执行此线程
}

可能上面的例子看起来和普通的单线程没两样,那我们先来看看下面这段代码的运行结果:

public static void main(String[] args) {
    Thread t = new Thread(() -> {
        System.out.println("我是线程:"+Thread.currentThread().getName());
        System.out.println("我正在计算 0-10000 之间所有数的和...");
        int sum = 0;
        for (int i = 0; i <= 10000; i++) {
            sum += i;
        }
        System.out.println("结果:"+sum);
    });
    t.start();
    System.out.println("我是主线程!");
}

在这里插入图片描述
我们发现,这段代码执行输出结果并不是按照从上往下的顺序了,因为他们分别位于两个线程,他们是同时进行的!如果你还是觉得很疑惑,我们接着来看下面的代码运行结果:

public static void main(String[] args) {
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 50; i++) {
            System.out.println("我是一号线程:"+i);
        }
    });
    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 50; i++) {
            System.out.println("我是二号线程:"+i);
        }
    });
    t1.start();
    t2.start();
}

我们可以看到打印实际上是在交替进行的,也证明了他们是在同时运行!

注意:我们发现还有一个 run 方法,也能执行线程里面定义的内容,但是 run 是直接在当前线程执行,并不是创建一个线程执行!

t1.run();
t2.run();

在这里插入图片描述
startrun 方法的主要区别在于:当程序调用 start 方法,一个新线程将会被创建,并且在 run 方法中的代码将会在新线程上运行,然而在你直接调用 run 方法的时候,程序并不会创建新线程,run 方法内部的代码将在当前线程上运行。大多数情况下调用 run 方法是一个 bug 或者变成失误。因为调用者的初衷是调用 start 方法去开启一个新的线程,这个错误可以被很多静态代码覆盖工具检测出来。

如果你想要运行需要消耗大量时间的任务,你最好使用 start 方法,否则在你调用 run 方法的时候,你的主线程将会被卡住。另外一个区别在于,一但一个线程被启动,你不能重复调用该 thread 对象的 start 方法,调用已经启动线程的 start 方法将会报 IllegalStateException 异常, 而你却可以重复调用 run 方法。

在这里插入图片描述

实际上,线程和进程差不多,也会等待获取 CPU 资源,一旦获取到,就开始按顺序执行我们给定的程序,当需要等待外部 IO 操作(比如 Scanner 获取输入的文本),就会暂时处于休眠状态,等待通知,或是调用 sleep() 方法来让当前线程休眠一段时间:

public static void main(String[] args) throws InterruptedException {
    System.out.println("l");
    Thread.sleep(1000);    //休眠时间,以毫秒为单位,1000ms = 1s
    System.out.println("b");
    Thread.sleep(1000);
    System.out.println("w");
    Thread.sleep(1000);
    System.out.println("nb!");
}

我们也可以使用 stop() 方法来强行终止此线程:

public static void main(String[] args) throws InterruptedException {
    Thread t = new Thread(() -> {
        Thread me = Thread.currentThread();   //获取当前线程对象
        for (int i = 0; i < 50; i++) {
            System.out.println("打印:"+i);
            if(i == 20) me.stop();  //此方法会直接终止此线程
        }
    });
    t.start();
}

虽然 stop() 方法能够终止此线程,但是并不是所推荐的做法,有关线程中断相关问题,我们会在后面继续了解。

思考:猜猜以下程序输出结果:

private static int value = 0;

public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 10000; i++) value++;
        System.out.println("线程1完成");
    });
    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 10000; i++) value++;
        System.out.println("线程2完成");
    });
    t1.start();
    t2.start();
    Thread.sleep(1000);  //主线程停止1秒,保证两个线程执行完成
    System.out.println(value);
}

在这里插入图片描述
我们发现,value 最后的值并不是我们理想的结果,有关为什么会出现这种问题,在我们学习到线程锁的时候,再来探讨。

2.线程的休眠和中断

我们前面提到,一个线程处于运行状态下,线程的下一个状态会出现以下情况:

  • 当 CPU 给予的运行时间结束时,会从运行状态回到就绪(可运行)状态,等待下一次获得 CPU 资源。
  • 当线程进入休眠 / 阻塞(如等待IO请求)/ 手动调用 wait() 方法时,会使得线程处于等待状态,当等待状态结束后会回到就绪状态。
  • 当线程出现异常或错误 / 被 stop() 方法强行停止 / 所有代码执行结束时,会使得线程的运行终止。

在这里插入图片描述

而这个部分我们着重了解一下线程的休眠和中断,首先我们来了解一下如何使得线程进如休眠状态:

public static void main(String[] args) {
    Thread t = new Thread(() -> {
        try {
            System.out.println("l");
            Thread.sleep(1000);   //sleep方法是Thread的静态方法,它只作用于当前线程(它知道当前线程是哪个)
            System.out.println("b");    //调用sleep后,线程会直接进入到等待状态,直到时间结束
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    });
    t.start();
}

通过调用 sleep() 方法来将当前线程进入休眠,使得线程处于等待状态一段时间。我们发现,此方法显示声明了会抛出一个 InterruptedException 异常,那么这个异常在什么时候会发生呢?

public static void main(String[] args) {
    Thread t = new Thread(() -> {
        try {
            Thread.sleep(10000);  //休眠 10 秒
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    });
    t.start();
    try {
        Thread.sleep(3000);   // 主线程休眠3秒,一定比线程 t 先醒来
        t.interrupt();   //调用 t 的 interrupt 方法
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

在这里插入图片描述

我们发现,每一个 Thread 对象中,都有一个 interrupt() 方法,调用此方法后,会给指定线程添加一个中断标记,以告知线程需要立即停止运行或是进行其他操作,由线程来响应此中断并进行相应的处理,我们前面提到的 stop() 方法是强制终止线程,这样的做法虽然简单粗暴,但是很有可能导致资源不能完全释放,而类似这样的发送通知来告知线程需要中断,让线程自行处理后续,会更加合理一些,也是更加推荐的做法。我们来看看 interrupt 的用法:

public static void main(String[] args) {
    Thread t = new Thread(() -> {
        System.out.println("线程开始运行!");
        while (true){   //无限循环
            if(Thread.currentThread().isInterrupted()){   //判断是否存在中断标志
                break;   //响应中断
            }
        }
        System.out.println("线程被中断了!");
    });
    t.start();
    try {
        Thread.sleep(3000);   //休眠3秒,一定比线程t先醒来
        t.interrupt();   //调用t的interrupt方法
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

在这里插入图片描述
通过 isInterrupted() 可以判断线程是否存在中断标志,如果存在,说明外部希望当前线程立即停止,也有可能是给当前线程发送一个其他的信号,如果我们并不是希望收到中断信号就是结束程序,而是通知程序做其他事情,我们可以在收到中断信号后,复位中断标记,然后继续做我们的事情:

public static void main(String[] args) {
    Thread t = new Thread(() -> {
        System.out.println("线程开始运行!");
        while (true){
            if(Thread.currentThread().isInterrupted()){   //判断是否存在中断标志
                System.out.println("发现中断信号,复位,继续运行...");
                Thread.interrupted();  //复位中断标记(返回值是当前是否有中断标记,这里不用管)
            }
        }
    });
    t.start();
    try {
        Thread.sleep(3000);   //休眠3秒,一定比线程t先醒来
        t.interrupt();   //调用t的interrupt方法
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

在这里插入图片描述
复位中断标记后,会立即清除中断标记。那么,如果现在我们想暂停线程呢?我们希望线程暂时停下,比如等待其他线程执行完成后,再继续运行,那这样的操作怎么实现呢?

public static void main(String[] args) {
    Thread t = new Thread(() -> {
        System.out.println("线程开始运行!");
        Thread.currentThread().suspend();   //暂停此线程
        System.out.println("线程继续运行!");
    });
    t.start();
    try {
        Thread.sleep(3000);   //休眠3秒,一定比线程t先醒来
        t.resume();   //恢复此线程
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

在这里插入图片描述
虽然这样很方便地控制了线程的暂停状态,但是这两个方法我们发现实际上也是不推荐的做法,它很容易导致死锁!有关为什么被弃用的原因,我们会在线程锁继续探讨。

3.线程的优先级

实际上,Java 程序中的每个线程并不是平均分配 CPU 时间的,为了使得线程资源分配更加合理,Java 采用的是抢占式调度方式,优先级越高的线程,优先使用 CPU 资源!我们希望 CPU 花费更多的时间去处理更重要的任务,而不太重要的任务,则可以先让出一部分资源。线程的优先级一般分为以下三种:

  • 最低优先级:MIN_PRIORITY
  • 最高优先级:MAX_PRIORITY
  • 常规优先级:NOM_PRIORITY
public static void main(String[] args) {
    Thread t = new Thread(() -> {
        System.out.println("线程开始运行!");
    });
    t.start();
    t.setPriority(Thread.MIN_PRIORITY);  //通过使用setPriority方法来设定优先级
}

优先级越高的线程,获得 CPU 资源的概率会越大,并不是说一定优先级越高的线程越先执行!

4.线程的礼让和加入

我们还可以在当前线程的工作不重要时,将 CPU 资源让位给其他线程,通过使用 yield() 方法来将当前资源让位给其他同优先级线程:

public static void main(String[] args) {
    Thread t1 = new Thread(() -> {
        System.out.println("线程1开始运行!");
        for (int i = 0; i < 50; i++) {
            if(i % 5 == 0) {
                System.out.println("让位!");
                Thread.yield();
            }
            System.out.println("1打印:"+i);
        }
        System.out.println("线程1结束!");
    });
    Thread t2 = new Thread(() -> {
        System.out.println("线程2开始运行!");
        for (int i = 0; i < 50; i++) {
            System.out.println("2打印:"+i);
        }
    });
    t1.start();
    t2.start();
}

观察结果,我们发现,在让位之后,尽可能多的在执行线程2的内容。

当我们希望一个线程等待另一个线程执行完成后再继续进行,我们可以使用 join() 方法来实现线程的加入:

public static void main(String[] args) {
    Thread t1 = new Thread(() -> {
        System.out.println("线程1开始运行!");
        for (int i = 0; i < 50; i++) {
            System.out.println("1打印:"+i);
        }
        System.out.println("线程1结束!");
    });
    Thread t2 = new Thread(() -> {
        System.out.println("线程2开始运行!");
        for (int i = 0; i < 50; i++) {
            System.out.println("2打印:"+i);
            if(i == 10){
                try {
                    System.out.println("线程1加入到此线程!");
                    t1.join();    //在i==10时,让线程1加入,先完成线程1的内容,在继续当前内容
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    });
    t1.start();
    t2.start();
}

在这里插入图片描述
我们发现,线程 1 加入后,线程 2 等待线程 1 待执行的内容全部执行完成之后,再继续执行的线程 2 内容。注意,线程的加入只是等待另一个线程的完成,并不是将另一个线程和当前线程合并!我们来看看:

public static void main(String[] args) {
    Thread t1 = new Thread(() -> {
        System.out.println(Thread.currentThread().getName()+"开始运行!");
        for (int i = 0; i < 50; i++) {
            System.out.println(Thread.currentThread().getName()+"打印:"+i);
        }
        System.out.println("线程1结束!");
    });
    Thread t2 = new Thread(() -> {
        System.out.println("线程2开始运行!");
        for (int i = 0; i < 50; i++) {
            System.out.println("2打印:"+i);
            if(i == 10){
                try {
                    System.out.println("线程1加入到此线程!");
                    t1.join();    //在i==10时,让线程1加入,先完成线程1的内容,在继续当前内容
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    });
    t1.start();
    t2.start();
}

在这里插入图片描述
实际上,t2 线程只是暂时处于等待状态,当 t1 执行结束时,t2 才开始继续执行,只是在效果上看起来好像是两个线程合并为一个线程在执行而已。

5.线程锁和线程同步

5.1 线程锁

线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的工作内存(本地内存),工作内存中存储了该线程以读/写共享变量的副本。它类似于我们在计算机组成原理中学习的多处理器高速缓存机制:
在这里插入图片描述
高速缓存通过保存内存中数据的副本来提供更加快速的数据访问,但是如果多个处理器的运算任务都涉及同一块内存区域,就可能导致各自的高速缓存数据不一致,在写回主内存时就会发生冲突,这就是引入高速缓存引发的新问题,称之为:缓存一致性。

实际上,Java 的内存模型也是这样类似设计的,当我们同时去操作一个共享变量时,如果仅仅是读取还好,但是如果同时写入内容,就会出现问题!好比说一个银行,如果我和我的朋友同时在银行取我账户里面的钱,难道取 1000 还可能吐 2000 出来吗?我们需要一种更加安全的机制来维持秩序,保证数据的安全性!

private static int value = 0;

public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 10000; i++) value++;
        System.out.println("线程1完成");
    });
    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 10000; i++) value++;
        System.out.println("线程2完成");
    });
    t1.start();
    t2.start();
    Thread.sleep(1000);  //主线程停止1秒,保证两个线程执行完成
    System.out.println(value);
}

在这里插入图片描述
实际上,当两个线程同时读取 value 的时候,可能会同时拿到同样的值,而进行自增操作之后,也是同样的值,再写回主内存后,本来应该进行 2 次自增操作,实际上只执行了一次!

那么要去解决这样的问题,我们就必须采取某种同步机制,来限制不同线程对于共享变量的访问!我们希望的是保证共享变量 value 自增操作的原子性(原子性是指一个操作或多个操作要么全部执行,且执行的过程不会被任何因素打断,包括其他线程,要么就都不执行)

通过 synchronized 关键字来创造一个线程锁,首先我们来认识一下 synchronized 代码块,它需要在括号中填入一个内容,必须是一个对象或是一个类,我们在 value 自增操作外套上同步代码块:

private static int value = 0;

public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 10000; i++) {
            synchronized (Main.class){
                value++;
            }
        }
        System.out.println("线程1完成");
    });
    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 10000; i++) {
            synchronized (Main.class){
                value++;
            }
        }
        System.out.println("线程2完成");
    });
    t1.start();
    t2.start();
    Thread.sleep(1000);  //主线程停止1秒,保证两个线程执行完成
    System.out.println(value);
}

在这里插入图片描述
我们发现,现在得到的结果就是我们想要的内容了,因为在同步代码块执行过程中,拿到了我们传入对象或类的锁(传入的如果是对象,就是对象锁,不同的对象代表不同的对象锁,如果是类,就是类锁,类锁只有一个,实际上类锁也是对象锁,是 Class 类实例,但是 Class 类实例同样的类无论怎么获取都是同一个),但是注意两个线程必须使用同一把锁!

当一个线程进入到同步代码块时,会获取到当前的锁,而这时如果其他使用同样的锁的同步代码块也想执行内容,就必须等待当前同步代码块的内容执行完毕,在执行完毕后会自动释放这把锁,而其他的线程才能拿到这把锁并开始执行同步代码块里面的内容。(实际上 synchronized 是一种悲观锁,随时都认为有其他线程在对数据进行修改,后面有机会我们还会讲到乐观锁,如 CAS 算法)

那么我们来看看,如果使用的是不同对象的锁,那么还能顺利进行吗?

private static int value = 0;

public static void main(String[] args) throws InterruptedException {
    Main main1 = new Main();
    Main main2 = new Main();
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 10000; i++) {
            synchronized (main1){
                value++;
            }
        }
        System.out.println("线程1完成");
    });
    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 10000; i++) {
            synchronized (main2){
                value++;
            }
        }
        System.out.println("线程2完成");
    });
    t1.start();
    t2.start();
    Thread.sleep(1000);  //主线程停止1秒,保证两个线程执行完成
    System.out.println(value);
}

在这里插入图片描述
当对象不同时,获取到的是不同的锁,因此并不能保证自增操作的原子性,最后也得不到我们想要的结果。

synchronized 关键字也可以作用于方法上,调用此方法时也会获取锁:

private static int value = 0;

private static synchronized void add(){
    value++;
}

public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 10000; i++) add();
        System.out.println("线程1完成");
    });
    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 10000; i++) add();
        System.out.println("线程2完成");
    });
    t1.start();
    t2.start();
    Thread.sleep(1000);  //主线程停止1秒,保证两个线程执行完成
    System.out.println(value);
}

在这里插入图片描述
我们发现实际上效果是相同的,只不过这个锁不用你去给,如果是静态方法,就是使用的类锁,而如果是普通成员方法,就是使用的对象锁。通过灵活的使用 synchronized 就能很好地解决我们之前提到的问题了!

5.2 死锁

其实死锁的概念在操作系统中也有提及,它是指两个线程相互持有对方需要的锁,但是又迟迟不释放,导致程序卡住。

线程 A 和线程 B 都需要对方的锁,但是又被对方牢牢把握,由于线程被无限期地阻塞,因此程序不可能正常终止。我们来看看以下这段代码会得到什么结果:

public static void main(String[] args) throws InterruptedException {
    Object o1 = new Object();
    Object o2 = new Object();
    Thread t1 = new Thread(() -> {
        synchronized (o1){
            try {
                Thread.sleep(1000);
                synchronized (o2){
                    System.out.println("线程1");
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    });
    Thread t2 = new Thread(() -> {
        synchronized (o2){
            try {
                Thread.sleep(1000);
                synchronized (o1){
                    System.out.println("线程2");
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    });
    t1.start();
    t2.start();
}

那么我们如何去检测死锁呢?我们可以利用 jstack 命令来检测死锁,首先利用 jps 找到我们的 java 进程:

nagocoler@NagodeMacBook-Pro ~ % jps
51592 Launcher
51690 Jps
14955 
51693 Main
nagocoler@NagodeMacBook-Pro ~ % jstack 51693
...
Java stack information for the threads listed above:
===================================================
"Thread-1":
	at com.test.Main.lambda$main$1(Main.java:46)
	- waiting to lock <0x000000076ad27fc0> (a java.lang.Object)
	- locked <0x000000076ad27fd0> (a java.lang.Object)
	at com.test.Main$$Lambda$2/1867750575.run(Unknown Source)
	at java.lang.Thread.run(Thread.java:748)
"Thread-0":
	at com.test.Main.lambda$main$0(Main.java:34)
	- waiting to lock <0x000000076ad27fd0> (a java.lang.Object)
	- locked <0x000000076ad27fc0> (a java.lang.Object)
	at com.test.Main$$Lambda$1/396873410.run(Unknown Source)
	at java.lang.Thread.run(Thread.java:748)

Found 1 deadlock.

jstack 自动帮助我们找到了一个死锁,并打印出了相关线程的栈追踪信息。

不推荐使用 suspend() 去挂起线程的原因,是因为 suspend() 在使线程暂停的同时,并不会去释放任何锁资源。其他线程都无法访问被它占用的锁。直到对应的线程执行 resume() 方法后,被挂起的线程才能继续,从而其它被阻塞在这个锁的线程才可以继续执行。但是,如果 resume() 操作出现在 suspend() 之前执行,那么线程将一直处于挂起状态,同时一直占用锁,这就产生了死锁。

5.3 wait 和 notify 方法

其实我们之前可能就发现了,Object 类还有三个方法我们从来没有使用过,分别是 wait()notify() 以及 notifyAll(),他们其实是需要配合 synchronized 来使用的,只有在同步代码块中才能使用这些方法,我们来看看他们的作用是什么:

public static void main(String[] args) throws InterruptedException {
    Object o1 = new Object();
    Thread t1 = new Thread(() -> {
        synchronized (o1){
            try {
                System.out.println("开始等待");
                o1.wait();     //进入等待状态并释放锁
                System.out.println("等待结束!");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    });
    Thread t2 = new Thread(() -> {
        synchronized (o1){
            System.out.println("开始唤醒!");
            o1.notify();     //唤醒处于等待状态的线程
          	for (int i = 0; i < 50; i++) {
               	System.out.println(i);   
            }
          	//唤醒后依然需要等待这里的锁释放之前等待的线程才能继续
        }
    });
    t1.start();
    Thread.sleep(1000);
    t2.start();
}

在这里插入图片描述
我们可以发现,对象的 wait() 方法会暂时使得此线程进入等待状态,同时会释放当前代码块持有的锁,这时其他线程可以获取到此对象的锁,当其他线程调用对象的 notify() 方法后,会唤醒刚才变成等待状态的线程(这时并没有立即释放锁)。注意,必须是在持有锁(同步代码块内部)的情况下使用,否则会抛出异常!

notifyAll 其实和 notify 一样,也是用于唤醒,但是前者是唤醒所有调用 wait() 后处于等待的线程,而后者是看运气随机选择一个。

5.4 ThreadLocal 的使用

既然每个线程都有一个自己的工作内存,那么能否只在自己的工作内存中创建变量仅供线程自己使用呢?
在这里插入图片描述
我们可以是 ThreadLocal 类,来创建工作内存中的变量,它将我们的变量值存储在内部(只能存储一个变量),不同的变量访问到 ThreadLocal 对象时,都只能获取到自己线程所属的变量。

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 对象,我们发现,第一个线程存放的内容,第一个线程可以获取,但是第二个线程无法获取,我们再来看看第一个线程存入后,第二个线程也存放,是否会覆盖第一个线程存放的内容:

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 存放数据,只会存放在线程自己的工作空间中,而不会直接存放到主内存中,因此各个线程直接存放的内容互不干扰。

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

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 来解决:

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 存放的内容,会自动向子线程传递。

6.定时器

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

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 秒钟执行一次代码,这样该怎么做呢?

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 也为我们提供了一套自己的框架用于处理定时任务:

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 内存维护了一个 任务队列 和一个 工作线程

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,是一个新创建的线程,在构造时自动启动:

public Timer(String name) {
    thread.setName(name);
    thread.start();
}

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

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() 方法来关闭它的工作线程:

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

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

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);
}

7.守护线程

Java 提供了两种线程:守护线程和用户线程。守护线程,是指在程序运行时 在后台提供一种通用服务的线程,这种线程并不属于程序中不可或缺的部分。通俗点讲,任何一个守护线程都是整个 JVM 中所有非守护线程的 “保姆”。其中具有代表性的就是:垃圾回收线程(守护线程)。

用户线程和守护线程几乎一样,唯一的不同之处在于如果用户线程已经全部退出运行,只剩下守护线程存在了,JVM 也就退出了。因为当所有非守护线程结束时,没有了被守护者,守护线程也就没有工作可做,当然也就没有继续执行的必要了,程序就会终止,同时会杀死所有的 “守护线程”,也就是说只要有任何非守护线程还在运行,程序就不会终止。

在 Java 语言中,守护线程一般具有较低的优先级,它并非只由 JVM 内部提供,用户在编写程序时也可以自己设置守护线程,例如:将一个线程设置为守护线程的方法就是在调用 start() 启动线程之前调用对象的 setDaemon(true) 方法,若将以上参数设置为 false,则表示的是用户进程模式,需要注意的是,当在一个守护线程中产生了其他的线程,那么这些新产生的线程默认还是守护线程,用户线程也是如此。

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

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);
    }
}

在这里插入图片描述
在守护线程中产生的新线程也是守护的:

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);
    }
}

8.再谈集合类并行方法

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

default Stream<E> parallelStream() {
    return StreamSupport.stream(spliterator(), true);
}

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

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() 方法来使用单线程维持原本的顺序:

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

在这里插入图片描述
我们之前还发现,在 Arrays 数组工具类中,也包含大量的并行方法:

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));
}

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

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));
}

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

9.编程实战:生产者与消费者问题

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

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

public class Main {
    private static final List<Object> list = new ArrayList<>();

    public static void main(String[] args) {
        Thread c1 = new Thread(Main::add);
        c1.setName("厨师1");
        Thread c2 = new Thread(Main::add);
        c2.setName("厨师2");

        c1.start();
        c2.start();

        Thread s1 = new Thread(Main::take);
        s1.setName("顾客1");
        Thread s2 = new Thread(Main::take);
        s2.setName("顾客2");
        Thread s3 = new Thread(Main::take);
        s3.setName("顾客3");

        s1.start();
        s2.start();
        s3.start();
    }

    private static void add(){
        while(true){
            try {
                Thread.sleep(3000);
                synchronized (list){
                    list.add(new Object());
                    System.out.println(new Date() + Thread.currentThread().getName() + "添加了新菜!");
                    list.notify();
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

        }
    }

    private static void take(){
        while(true){
            try {
                synchronized (list) {
                    while(list.isEmpty()) list.wait();
                    System.out.println(new Date() + Thread.currentThread().getName() + "拿走了菜!");
                    list.remove(0);
                }
                Thread.sleep(4000);
            } catch(InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

在这里插入图片描述

  • 6
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

G皮T

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值