前面我们已经讲解了JavaSE的大部分核心内容,最后一章,我们还将继续学习JavaSE中提供的各种高级特性。这些高级特性对于我们之后的学习,会有着举足轻重的作用。
注意: 本章节会涉及到 操作系统 相关知识。
在了解多线程之前,让我们回顾一下操作系统
中提到的进程概念:
进程是程序执行的实体,每一个进程都是一个应用程序(比如我们运行QQ、浏览器、LOL、网易云音乐等软件),都有自己的内存空间,CPU一个核心同时只能处理一件事情,当出现多个进程需要同时运行时,CPU一般通过时间片轮转调度
算法,来实现多个进程的同时运行。
在早期的计算机中,进程是拥有资源和独立运行的最小单位,也是程序执行的最小单位。但是,如果我希望两个任务同时进行,就必须运行两个进程,由于每个进程都有一个自己的内存空间,进程之间的通信就变得非常麻烦(比如要共享某些数据)而且执行不同进程会产生上下文切换,非常耗时,那么能否实现在一个进程中就能够执行多个任务呢?
后来,线程横空出世,一个进程可以有多个线程,线程是程序执行中一个单一的顺序控制流程,现在线程才是程序执行流的最小单元,各个线程之间共享程序的内存空间(也就是所在进程的内存空间),上下文切换速度也高于进程。
在Java中,我们从开始,一直以来编写的都是单线程应用程序(运行main()
方法的内容),也就是说只能同时执行一个任务(无论你是调用方法、还是进行计算,始终都是依次进行的,也就是同步的),而如果我们希望同时执行多个任务(两个方法同时在运行或者是两个计算同时在进行,也就是异步的),就需要用到Java多线程框架。实际上一个Java程序启动后,会创建很多线程,不仅仅只运行一个主线程:
public static void main(String[] args) {
ThreadMXBean bean = ManagementFactory.getThreadMXBean();
long[] ids = bean.getAllThreadIds();
ThreadInfo[] infos = bean.getThreadInfo(ids);
for (ThreadInfo info : infos) {
System.out.println(info.getThreadName());
}
}
关于除了main线程默认以外的线程,涉及到JVM相关底层原理,在这里不做讲解,了解就行。
通过创建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是直接在当前线程执行,并不是创建一个线程执行!
实际上,线程和进程差不多,也会等待获取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最后的值并不是我们理想的结果,有关为什么会出现这种问题,在我们学习到线程锁的时候,再来探讨。
我们前面提到,一个线程处于运行状态下,线程的下一个状态会出现以下情况:
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();
}
}
虽然这样很方便地控制了线程的暂停状态,但是这两个方法我们发现实际上也是不推荐的做法,它很容易导致死锁!有关为什么被弃用的原因,我们会在线程锁继续探讨。
实际上,Java程序中的每个线程并不是平均分配CPU时间的,为了使得线程资源分配更加合理,Java采用的是抢占式调度方式,优先级越高的线程,优先使用CPU资源!我们希望CPU花费更多的时间去处理更重要的任务,而不太重要的任务,则可以先让出一部分资源。线程的优先级一般分为以下三种:
public static void main(String[] args) {
Thread t = new Thread(() -> {
System.out.println("线程开始运行!");
});
t.start();
t.setPriority(Thread.MIN_PRIORITY); //通过使用setPriority方法来设定优先级
}
优先级越高的线程,获得CPU资源的概率会越大,并不是说一定优先级越高的线程越先执行!
我们还可以在当前线程的工作不重要时,将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才开始继续执行,只是在效果上看起来好像是两个线程合并为一个线程在执行而已。
在开始讲解线程同步之前,我们需要先了解一下多线程情况下Java的内存管理:
线程之间的共享变量(比如之前悬念中的value变量)存储在主内存(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次自增操作,实际上只执行了一次!
通过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){ //使用synchronized关键字创建同步代码块
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是一种悲观锁,随时都认为有其他线程在对数据进行修改,后面在JUC篇视频教程中我们还会讲到乐观锁,如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就能很好地解决我们之前提到的问题了。