文章目录
- 1. 认识线程
- 1.1 什么是进程
- 1.2 什么是线程
- 1.2.1. 线程是怎么做到的呢?
- 1.2.2. 进程和线程的关系
- 1.3 多线程编程
- 1.3.1. 第一个多线程程序
- 1.3.2. 使用 jconsole 命令查看线程
- 1.3.3. 实现 Runnable 接口,重写 run
- 1.3.4. 继承 Thread 重写 run,并使用匿名内部类
- 1.3.5. 实现 Runnable,重写 run,匿名内部类
- 1.3.6. 【推荐/常用】使用 lambda 表达式
- 2. Thread 类及常见方法
- 2.1 Thread 的常见构造方法
- 2.2 Thread 的几个常见属性
- 2.3 启动⼀个线程 - start()
- 关于start 经典面试题:
- 2.4 中断⼀个线程
- 用自定义变量作为标志位
- 使用 Thread.interrupted() 或者 Thread.currentThread().isInterrupted()
- 2.5 等待⼀个线程 - join()
- join 的几个方法:
- 2.6 获取当前线程引用
- 2.7 休眠当前线程
- 3. 线程的状态
- 3.1 观察线程的所有状态
- 4. 多线程带来的的风险 - 线程安全
- 4.1 线程安全的概念
- 4.2 线程不安全的例子
- 4.3 线程不安全的原因
- 4.4 加锁 - synchronized
- 4.4.1 用 synchronized 关键字进行加锁
- 4.4.2 synchronized 的特性
在Java 开发中,我们并不鼓励“多线程编译”,因此线程就更加重要了
1. 认识线程
1.1 什么是进程
在多任务操作系统中,我们希望程序能够同时巡行多个程序
不过,如果是单任务的程序,完全不涉及进程,也不需要调度
本质上来说,进程解决“并发编程”这样的问题的
事实上,进程,是可以很好的解决并发编程这样的问题的
再一些特定的情况下,进程的表现,不是尽人意的
比如,有些场景下,需要频繁的创建和销毁进程的过程,此时使用多进程编程,系统开销就会很大,其中最关键的原因,就是资源的申请和释放
进程是资源(CPU,硬盘,内存,网络带宽…)分配的基本单位
一个进程,更高启动的时候吗,首当其冲的就是内存资源,进程需要把依赖的代码和数据,从磁盘加载到内存中
1.2 什么是线程
线程 就是解决上述问题的方案
线程也可以称为“轻量级进程”,在进程的基础上,做出了改进
即保持了独立调度执行,这样的“并发支持”,同时省去“分配资源”“释放资源”带来的额外开销
1.2.1. 线程是怎么做到的呢?
前面介绍了会使用PCB 来描述一个进程
现在,也是用PCB来描述一个线程
PCB中有个属性,是内存指针
多线程的PCB 的内存指针,指向的是同一个内存空间
这样就意味着,只是创建第一个线程的时候需要从系统分配资源,后续的线程,就不必分配,直接共用前面的那份资源就可以了
除了内存之外,文件描述表(操作硬盘),这个东西也是多个线程共用一份的
操作系统,进行“多任务调度”,本质上是在调度PCB(线程在系统中的调度规则,就和之前的进程是一样的)
但是也不是随便搞两个线程,节能资源共享
把能够资源共享的这些线程,分成组,称为“线程组”。换句话将,线程组,也就是进程的一部分
每个进程,都可与包含一个线程或者多个线程
1.2.2. 进程和线程的关系
有线程之前,进程需要扮演两个角色(资源分配的基本单位,也是调度执行的基本单位)
有了线程之后,就把这两个角色分开了
进程专注于资源分配
线程扶着调度执行
在创建进程,资源就分配了,只不过,一个进程中至少包含一个线程(创建第一个线程的同时,进程也就出来了)
进程和线程的关系/区别,是非常经典、非常高频的面试题
- 进程是包含线程的
- 每个线程,也是一个独立的执行流,可以只想一些代码,并且单独的参与到cpu 调度中(状态、上下文、优先级、记账信息,每个线程有自己的一份)
- 每个进程,有自己的资源,进程中的线程共用这一份资源(内存空间 和 文件描述符表)
进程是资源分配的基本单位,线程是调度执行的基本单位
-
进程和进程之间,不会相互影响。如果同一个进程中的某个线程,抛出异常,是可能会影响到其他线程,会把整个进程中的所有线程都异常终止
-
同一个进程中的线程之间,可能会相互干扰,引起线程安全问题
-
线程也不是越多越好,要能够合适。如果能够线程太多了,调度开销可能非常明显
1.3 多线程编程
写代码的时候,可以使用多进程进行并发编程,也可以使用多线程并发编程
但是在java 中并不推荐多进程开发,很多和多进程编程相关的api 在 java 标准库中,都没有提供
可是系统提供了多线程编程的api ,java 变转库,把这些api封装了,在代码中就可以使用了
1.3.1. 第一个多线程程序
Java 提供了 api,Thread这样的类
第一种方法就是继承 Thread 重写 run
package thread;//1、创建一个自己的类,继承自这个 Thread
class MyThread extends Thread {//这个类好像直接可以使用,不需要导入包//Java 标准库中,有一个特殊的包,java.lang 这个可以直接使用//class 前面不可以加public 因为一个java文件中,只能有一个public 的类//这个类如果没有public 包级作用域,就只能在当前包里被其他的类使用@Override//方法重写,本质上,是让你能够对现有的类,进行扩展/*** 目前需要写一个线程,肯定需要让这个线程执行一些代码* Thread 类本身会带有一个run入口方法* 很明显,标准库自带的run是不知道你的需求 业务是什么样的 必须手动指定* 因此就可以针对原有的Thread进行扩展* (把一些能复用的,进行了重用,需要扩展的,进行扩展)* Thread会有很多属性方法,大部分内容都复用即可* 只是把需要扩展的这个进行扩展即可*/public void run() {//run 方法就是该线程的入口方法//就类似于,main方法,是一个java进程(程序)的入口方法/*** 在以后的学习中,一般把跑起来的程序,称为"进程",没有运行起来的程序(exe),称为”可执行文件“* 一个进程中,至少会有一个线程* 这个进程中的第一个线程,也就称为“主线程”* main方法,也就是主线程的入口方法*///此处的run方法,不需要程序员手动调用,会在合适的时机(线程创建好了之后),被jvm 自动调用/*** 这种风格的函数,称为“回调函数”(callback)* 回调函数是编程中非常重要的函数* 优先级队列 PriorityQueue:* 指定比较规则* Comparable 和 Comparator* 自己和别人比 你妈妈拿你和别人家的孩子比* 如果使用Comparable,意味着你这个类只能有一种比较规则,毕竟一个类,只能实现一次Comparable。这种写法对类侵入性比较强* 使用Comparator,意味着可以有对重比较规则** compareTo 和 compare 这俩方法,就属于“回调函数”*/System.out.println("hello world");}
}public class ThreadDemo1 {public static void main(String[] args) {//2、根据刚在线程的类,创建出实例(线程实例才是真正的线程)Thread myThread = new MyThread();//3、调用 Thread 的 start 方法,才会真正调用系统api,在系统内核中创建出线程myThread.start();//虽然,没有手动调用run,但是run还是执行了,这就是jvm自动调用了//虽然看起来跟以前没有什么区别,但是当引入多线程之后,代码就可以同时具备多个执行流了}
}
多线程
class MyThread2 extends Thread {@Overridepublic void run() {while (true) {System.out.println("hello world");}}
}public class TreadDemo2 {public static void main(String[] args) {Thread thread = new MyThread2();thread.start();/*** 此处,调用start 创建线程之后 兵分两路* 一路,沿着 main 方法继续执行,打印 hello main* 另一路,进入线程的 run 方法,打印 hello world** 注意:* 当有多个线程的时候 这些线程执行的先后顺序,是不确定的* 这一点,是因为操作系统内核中,有一个“调度器模块,这个模块的视线方式,是一种类似于”随机调度“效果** 什么叫做“随机调度”:* 1.一个线程,什么时候被调度到cpu上执行,时机是不确定的* 2.一个线程,什么时候从cpu上下来,给别人让位,时机也是不确定的* 这叫做“抢占式执行” 当前的主流操作系统,都是抢占式执行**/while (true) {System.out.println("hello main");}}
}
真正运行程序,可以看到,两个程序都在,执行
由于程序死循环,对电脑cpu功耗太大
可以在循环中,加上sleep 进行休眠
class MyThread2 extends Thread {@Overridepublic void run() {while (true) {System.out.println("hello world");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}}
}public class TreadDemo2 {public static void main(String[] args) throws InterruptedException {Thread thread = new MyThread2();thread.start();while (true) {System.out.println("hello main");Thread.sleep(1000);}}
}
1.3.2. 使用 jconsole 命令查看线程
第一个main 就是main方法对应的主线程
Thread-0 就是自己写的代码,创建的thread 线程
其余的线程,都是JVM自带的线程,来完成垃圾回收,监控统计各种指标,把统计指标通过网络的方式,传输给其他程序
线程的调用栈(线程里当前执行到了那个方法的第几行代码了,这个方法是如何一层一层调用过去的)
1.3.3. 实现 Runnable 接口,重写 run
package thread;class MyThread3 implements Runnable {/*** Runnable 可以解释成“可执行的”* 通过这个接口,就可以抽象出一段可以被其他实体来执行的代码* 这个Runnable 不仅仅可以搭配线程来执行*/@Overridepublic void run() {while (true) {System.out.println("hello runnable");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}}
}public class ThreadDemo3 {public static void main(String[] args) {Runnable runnable = new MyThread3();/*** 只是一段可以执行的代码* 还是需要Thread 类,才能真正在系统中创建出线程* 这种写法 其实就是把 线程和要执行的任务 进行了 解耦合*/Thread t = new Thread(runnable);t.start();while (true) {System.out.println("hello main");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}}
}
1.3.4. 继承 Thread 重写 run,并使用匿名内部类
匿名内部类,是在一个类里面定义的类
package thread;public class ThreadDemo4 {public static void main(String[] args) {Thread t = new Thread(){/*** 写{} 意思是要定义一个类 于此同时,这个新的类,继承与Thread* 与此同时,这个新的类,继承自Thread* 此处{} 中可以定义子类的属性和方法* 此处最重要的目的就是重写run方法*//*** 此处的 t 并非单纯的Thread* 而是Thread 的子类* 与此同时,这个代码还创建了子类的实例*/@Overridepublic void run() {while (true) {System.out.println("hello thread");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}}};t.start();while (true) {System.out.println("hello main");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}}
}
1.3.5. 实现 Runnable,重写 run,匿名内部类
package thread;public class ThreadDome5 {public static void main(String[] args) {Thread t = new Thread(new Runnable() {//Thread 构造方法的参数,填写了Runnable 的匿名内部类的实例@Overridepublic void run() {while (true) {System.out.println("hello runnable");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}}});t.start();while (true) {System.out.println("hello main");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}}
}
匿名内部类,这种写法非常常见
这里主要的目的,是描述这个方法(设置回调函数)
方法不能脱离类,单独存在
这就导致为了设置回调函数,就不得不上一层类了
1.3.6. 【推荐/常用】使用 lambda 表达式
由于上几个代码非常麻烦,这里就引入了 lambda 表达式
lambda 表达式在主流语言中都有,只不过在其他语言不一定叫做 lambda
在 C++、Python,是叫做 lambda
JS、Go,直接叫做匿名函数
lambda 表达式/匿名内部 是可以访问到外面定义的局部变量的(变量捕获语法规则)
lambda 表达式 打破了类比喻和方法绑定在一起的形式,函数式接口属于 lambda 背后的实现,相当于 java 在没有破坏原有的规则的基础上,给了lambda一个合理的解释
package thread;public class ThreadDemo6 {public static void main(String[] args) {//常用/推荐使用 lambda表达式Thread t = new Thread(() -> {//形参列表,这里可以带参数,现成的入口不需要参数,比如lambda 代替 Comparator,可以带上两个参数while (true) {System.out.println("hello thread");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}});t.start();}
}
2. Thread 类及常见方法
Thread 类是 JVM ⽤来管理线程的⼀个类,换句话说,每个线程都有⼀个唯⼀的 Thread 对象与之关联
2.1 Thread 的常见构造方法
Thread t1 = new Thread();
Thread t2 = new Thread(new MyRunnable());
Thread t3 = new Thread("这是我的名字");
Thread t4 = new Thread(new MyRunnable(), "这是我的名字");
注意:
线程之间的名字是可以重复的
同一个工作,需要多个线程完成,都可以起一样的名字
但是名字不要乱起,最好还是要有一定的描述性
2.2 Thread 的几个常见属性
getId()
JVM自动分配的身份标识,会保证唯一性
getMane()
这个线程的名称
getState()
进程有状态(就绪状态,堵塞状态)
线程也有状态
Java 中对线程的状态,又进行了进一步的区分(比系统原生的状态,更丰富一些)
getPriority()
线程的优先级
在 java 中,设置优先级,效果不明显(对内核调度器的调试过程产生一些影响)
isDaemon()
daemon 守护
意思是是否是守护线程,可以叫做是否是“后台线程”
和后台线程相对的,还有前台线程
public class ThreadDemo7 {public static void main(String[] args) {Thread t = new Thread(new Runnable() {@Overridepublic void run() {while (true) {System.out.println("hello thread");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}}},"这是我的线程");t.setDaemon(true);//设为true 是后台 不设就是前台/*** 设为 true 是后台 (后台,是躲在背后的人,你感知不到) 后套不会阻止进程结束* 不设为 true 是前台(前台,是明面上的人,你能感知到) 前台会阻止进程的结束*/t.start();}
}
在代码创建的线程,默认是前台线程,会阻止进程的结束。只要前台线程没执行完,线程就不会结束,即使main已经执行结束
加上 t.setDaemon(true); 之后 再次执行,就会发现进程结束
isAlive()
表示了,内核中的线程(PCB)是否还存在
java 代码中定义的线程对象(Thread)实例,虽然表示一个线程,这个对象本身的生命周期,和内核中的pcb生命周期,是不完全一样的
这个时候,t 对象有了,但是内核 pcb 还没有,isAlive 就是 false
真正在内核中创建出这个 pub,此时 isAlive 就是 true 了
当线程 run 执行完了,此时 内核中的线程就结束了(内核 pcb 就释放了)
但是此时 t 变量可能还存在,浴室 isAlive 也是 false
2.3 启动⼀个线程 - start()
Thread 类使用 start 方法,启动一个线程
但是,要记住,对于同一个 Thread 对象来说,start 只能调用一次
public class ThreadDemo9 {public static void main(String[] args) {Thread t = new Thread(() -> {while (true) {System.out.println("hello thread");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}});t.start();//第二次调用 start 就会出现问题t.start();}
}
在这里可以看到,用两个start 会导致非法的线程状态异常
public static void main(String[] args) {Thread t = new Thread(() -> {System.out.println("hello");});t.start();try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}//第二次调用 start 就会出现问题t.start();}
这个时候,第一个线程虽然已经结束了,但是依然不可能用start 进入第二个线程,会导致线程状态异常
如果想要启动更多的线程,就得创建新的对象
public class ThreadDemo10 {public static void main(String[] args) {Thread t = new Thread(() -> {System.out.println("hello1");});Thread t2 = new Thread(() -> {System.out.println("hello2");});t.start();t2.start();}
}
调用 start 可以创建出新的线程
本质上是 start 会调用系统的 api,来完成创建线程的操作
关于start 经典面试题:
start 和 run 区别
start 和 run 其实互不相干
class MyThread4 extends Thread {@Overridepublic void run() {System.out.println("hello");}
}public class ThreadDemo11 {public static void main(String[] args) {Thread t = new MyThread4();//t.start();t.run();}
}
在这里调用 t.start(); 和 调用 t.run(); 都是打印出来的 hello ,看起来执行结果是一样的
所以就会有人陷入的疑惑,start 和 run 是不是一样的呢?
答案是否定的,通过run 执行,并没有创建新的线程,还是在main 主线程中打印的 hello
start 则是创建了新的线程,由新的线程来去执行 hello
这里我们变换一下代码,仔细看一看这两者之间的区别
class MyThread4 extends Thread {@Overridepublic void run() {while (true) {System.out.println("hello thread");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}}
}public class ThreadDemo11 {public static void main(String[] args) throws InterruptedException {Thread t = new MyThread4();//t.start();t.run();while (true) {System.out.println("hello main");Thread.sleep(1000);}}
}
如果调用的是run,那么就会进入到run 方法里面,这样并不会执行下面的代码
class MyThread4 extends Thread {@Overridepublic void run() {while (true) {System.out.println("hello thread");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}}
}public class ThreadDemo11 {public static void main(String[] args) throws InterruptedException {Thread t = new MyThread4();t.start();//t.run();while (true) {System.out.println("hello main");Thread.sleep(1000);}}
}
然而用 start 本质上是创建了新的线程,新的线程来执行这里的run,原有的 main 线程继续执行后续的循环
牢记,start 的使命,就是立即的在内核中创建出一个新的线程,新的线程和之前的线程,是“并发执行”的关系
2.4 中断⼀个线程
换一种说法就是终止一个线程
对于中断这个词是有多种含义的,在操作系统底层,也有中断概念,CPU与其他各种设备上,也有中断的概念
那怎么终止一个线程呢,其实也就是让线程 run 方法(入口方法)执行完毕
那我们如何让线程提前终止呢?
其核心就是让 run 方法能够提前结束,这也取决于具体代码的实现方式
用自定义变量作为标志位
为了让线程结束,我们引入了标志位
public class ThreadDemo12 {private static boolean isQuit = false;public static void main(String[] args) {Thread t =new Thread(() -> {while (!isQuit) {System.out.println("我是一个线程,工作中");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}//由于当前是个死循环,给了错误提示System.out.println("线程工作完毕!");});t.start();try {Thread.sleep(3000);} catch (InterruptedException e) {e.printStackTrace();}System.out.println("让 t 线程结束!");isQuit = true;}
}
通过上述代码,就可以让线程结束掉
具体线程啥时候结束,取决于在另一个线程中何时修改 isQuit 的值
main 线程,要想让 t 线程结束,大前提,一定是 t 线程的代码,对这样的逻辑有所支持,而不是 t 里面的代码让其结束
如果代码没有配合 main 无法让 t 提前结束
并且谨记,run 方法和 main 方法是两个线程,这两个线程的执行顺序是不确定的
但是如果我想把代码稍微改一下
public class ThreadDemo12 {private static boolean isQuit = false;public static void main(String[] args) {Thread t =new Thread(() -> {while (!isQuit) {System.out.println("我是一个线程,工作中");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}//由于当前是个死循环,给了错误提示System.out.println("线程工作完毕!");});t.start();try {Thread.sleep(3000);} catch (InterruptedException e) {e.printStackTrace();}isQuit = true;System.out.println("让 t 线程结束!");}
}
这里的 isQuit 是 作为全局变量,如果作为 main 方法中的局部变量,是否可行呢?
很明显是错误的
在 lambda 表达式/匿名内部类中是可以访问到外面定义的局部变量的
但是捕获的变量,必须是 final 或者 “事实final”(虽然没写final,但是没有修改)
但是如果写了final 后面就没有办法修改,因此不能写,局部变量是行不通的
因此我们就必须写成成员变量
lambda 表达式,本质上是“函数式接口”,也就是匿名内部类
内部类,访问外部类的成员,这个事情本身就是可以的,这个事情本身就不受到变量捕获的影响
那什么 java 中对变量捕获有final 的限制?
isQuit 是局部变量的时候是属于 main 方法的栈帧中,但是 Thread lambda 是有自己独立的栈帧的,这个时候两个栈帧的声明周期是一致的
这就会导致,main 方法执行完了,栈帧销毁了,同时 Thread 的栈帧还在,还想继续使用 isQuit
所以,在java 中的做法就非常简单了,变量捕获本质上就是传参,换句话说,就是让 lambda 表达式在自己的栈帧中创建一个 新的 isQuit 并且把外面的 isQuit 值拷贝过来(为了避免里外的 isQuit 的值不同步,java干脆就不让你 isQuit 修改)
使用 Thread.interrupted() 或者 Thread.currentThread().isInterrupted()
Thread.currentThread() 获取当前实例
在代码中那个线程调用,就得到的是哪个线程的实例(雷诗雨 this)
public class ThreadDemo13 {public static void main(String[] args) {Thread t = new Thread(() -> {while (!Thread.currentThread().isInterrupted()) {System.out.println("我是一个线程,工作中...");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}System.out.println("线程执行完毕!");});t.start();try {Thread.sleep(3000);} catch (InterruptedException e) {e.printStackTrace();}//使用一个 interrupt 方法,来改变刚才标志位的值System.out.println("让 t 线程结束!");t.interrupt();}
}
这里的代码就是把上面的 判定isQuit 改成判定 isInterrupted
这个代码本质上,就是使用 Thread 实例内部自带的标志位,来替代刚才手动创建的 isQuit 变量
但是运行完之后,会发现事实上跟预想的并不一样
这里 t 线程并没有真的结束
刚刚写的 interrupt 导致 sleep 出现了异常
如果没有 sleep,interrupt 可以让线程顺利结束,但是有了sleep 却引起了变数
我们在执行 sleep 的工程中,调用了interrupt,大概率的情况下,sleep 休眠时间还没到,就被提前唤醒了
提前唤醒,会出现两种情况:
- 抛出 InterruptedException 异常(紧接着就会被 catch 获取到)
- 清除 Thread 对象的 isInterrupted 标志位
意思就是,刚刚已经通过 interrupt 方法,把标志位设为了 true,但是 sleep 提前唤醒操作,就把标志位又设回了 false,因此循环回继续执行
其实要想要线程结束也很简单,在catch 中加入break 就可以了
public class ThreadDemo13 {public static void main(String[] args) {Thread t = new Thread(() -> {while (!Thread.currentThread().isInterrupted()) {System.out.println("我是一个线程,工作中...");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();//加上 break 此时抛出异常以后,直接跳出break;}}System.out.println("线程执行完毕!");});t.start();try {Thread.sleep(3000);} catch (InterruptedException e) {e.printStackTrace();}//使用一个 interrupt 方法,来改变刚才标志位的值System.out.println("让 t 线程结束!");t.interrupt();}
}
这里就也可以结束线程了
那么下面的那一串红色的是什么呢?
那个其实是日志,如果把 e.printStackTrace() 注释掉就不会存在了
sleep 为什么要清空标志位呢?
其实这里是为了给程序员更多的“可操作性空间”
在前一个代码,写的是 sleep(1000),结果现在 1000 还没到,就要终止线程,这就相当于两个前后矛盾的操作。此时,是希望有更多的代码对这种情况进行操作
此时我们就可以在catch 语句中,加入一些代码,来做一些处理
- 让线程立刻结束 (加上break)
- 让线程不结束,继续执行 (不加break)
- 让线程执行一些逻辑之后,再结束 (写一些其它代码,再break)
对于异常的处理我们有以下几种方法:
- 尝试自动恢复
能自动恢复,就尽量自动恢复,比如出现一个网络通信相关的异常,就可以在 catch 尝试重连网络 - 记录日志(异常心心记录到文件中)
有些情况,并非很严重的问题,只需要把这个问题记录下来即可(并不需要立即解决) - 发出警报
针对一些比较严重的问题,包括并不限于(发邮件、打电话、发短信、发微信) - 也有少数的正常的业务逻辑,会一来到 catch
比如文件操作中有的犯法,就是要通过 catch 来结束循环之类的
在java 中,线程的终止,是一种“软性”操作
必须要对应的线程配合,才能把终止落实下去
相比之下,系统原生的 api,其实还提供了,前置终止线程的操作
无论代码是否愿意配合,无论线程执行到哪个代码,都能强行让这个线程终止
但是这种操作,在java 的api 中没有提供,上述的做法,如果强行终止一个线程,可能线程执行到一半,就可能出现一些残留的临时性质的“错误”数据,其实是弊大于利的
2.5 等待⼀个线程 - join()
在操作系统中,多个线程的执行顺序是不确定的(随机调度,抢占式执行),有时,我们需要等待⼀个线程完成它的⼯作后,才能进⾏⾃⼰的下⼀步⼯作
虽然线程底层的调度是无需的,但是可以在应用程序中,通过一些api,来影响线程执行的顺序
join 就是之中方式,可以影响此线程结束的先后顺序
接下来,我们来写一段代码,看看join 的作用
public static void main(String[] args) throws InterruptedException {Thread t = new Thread(() -> {for (int i = 0; i < 5; i++) {System.out.println("我是一个线程,正在工作...");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}System.out.println("线程执行结束");});t.start();System.out.println("这个是主线程,期望这个日志在t结束后打印");}
在这段代码中,很明显,我们想要的是线程最后一个一行打印是在最后的,但是由于 t.start 让线程兵分两路,让一个接着main 函数执行,一个进入到了 Thread 中,所以没有办法按照预期执行
由于 run 方法中的内容执行时间无法预期,使用 join 就可以很好的解决问题
public class ThreadDemo14 {public static void main(String[] args) throws InterruptedException {Thread t = new Thread(() -> {Random random = new Random();int n = 5;for (int i = 0; i < n; i++) {System.out.println("我是一个线程,正在工作...");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}System.out.println("线程执行结束");});t.start();//这个操作就是线程等待t.join();System.out.println("这个是主线程,期望这个日志在t结束后打印");}
}
在 main 线程中,调用 t.ioin()
就是让 main 线程 等待 t 线程结束
在哪个线程调用就是等待的一方,哪个实例被调用就是被等待的一方
执行 join 的时候没就看 t 线程是否正在运行,如果 t 运行中,mian 线程就会阻塞(main 线程就展示不去 cpu 执行了),如果 t 运行结束,main 线程就会从阻塞中恢复过来,并且继续往下执行
(阻塞,使这两个线程的结束时间,产生了先后关系)
上述线程结束顺序的先后,在代码中,是通过 api 控制的
让 main 线程主动放弃了去调度器中调度,t 现车个虽然也可能是和其他线程共同进行调度的,由于主线程一直等待,即使 t 中间也经历多次 cpu 的切换,不影响,最终 t 也能正确先执行完毕
在任何一个线程都可以调用 join,哪个线程调用 join 哪个线程就阻塞等待
创建一个新线程进行运算
public class ThreadDemo15 {//t 线程吧计算的结果放到 result 中private static int result = 0;public static void main(String[] args) throws InterruptedException {Thread t = new Thread(() -> {for (int i = 1; i <= 1000; i++) {result += i;}});t.start();//主要是不知道 t 此案成要执行多久//Thread.sleep(1000);//使用 join,就会严格按照 t 线程结束来作为等待的条件//什么时候 t 运行结束(计算完毕),什么时候,join 就结束等待t.join();//如果主线程直接就打印 result,此时得到的结果是什么,是无法预期的//由于主线程之间的执行顺序是不确定的,主线程打印的 result 可能是还没有开始计算的初始值 0//也可能是计算过程中的证件结果,也可能是 t 线程计算完之后的最终结果System.out.println("result = " + result);}
}
上面是使用一个线程来进行计算,如果运算量足够的大,就可以来用多个线程进行计算
public class ThreadDemo15 {//t 线程吧计算的结果放到 result 中private static int result = 0;public static void main(String[] args) throws InterruptedException {Thread t = new Thread(() -> {for (long i = 1; i <= 100_0000_0000L; i++) {result += i;}});t.start();long beg = System.currentTimeMillis();t.join();long end = System.currentTimeMillis();System.out.println("result = " + result);System.out.println("time: " + (end - beg) + "ms");}
}
这个时候就会溢出,导致出错
所以我们就可以选择用多个线程进行计算
由此可以看出来,多线程是能够有效的提高程序的运行效率的
这个时候,主线程继续执行 join 主色
t 线程,执行 t 线程的逻辑,负责计算钱50亿的数据
t2 线程,执行 t2 线程的逻辑
这算个线程,兵分三路,并发执行(并发 = 并行 + 并发)
t 和 t2 可能在两个不同的核心上同时执行(并行)
t 和 t2 也可能在同一个核心上分时复用(并发)
具体执行过程,宏观上感知不到,但是总的来说,在cpu 不太繁忙的情况下,大概率还是并行执行的过程更多一点
记住,多线程代码是变幻莫测的,稍微一调整,逻辑都可能截然不同
join 的几个方法:
public void join() 等待线程结束(死等)
这个是不科学的,如果代码因为死等导致代码卡住,无法执行后面的逻辑,就会导致很严重的bug
public void join(long millis) 等待线程结束,最多等millis 毫秒
带有超时时间的等,等有一个时间的上限的(超时时间)
public void join(long millis,int nanos) 同理,可以更高精度
设置一个ns 级别的时间,实际的用处不大,系统时间也没法精确到 ns
2.6 获取当前线程引用
public static Thread currentThread(); 返回当前线程对象的引用
class MyThread5 extends Thread {@Overridepublic void run() {//这个代码中,如果想获取到线程的引用,直接使用 this 即可System.out.println(this.getId() + "," + this.getName());}
}public class ThreadDemo16 {public static void main(String[] args) throws InterruptedException {MyThread5 t1 = new MyThread5();MyThread5 t2 = new MyThread5();t1.start();t2.start();Thread.sleep(1000);System.out.println(t1.getId() + "," + t1.getName());System.out.println(t2.getId() + "," + t2.getName());}
}
这个方法也是可以获取到当前的引用对象的
如果是继承 Thread,直接使用 this 拿到咸亨实例
如果是 Runnable 或者 lambda 的方式,this 就无能为力了,超时 this 已经不再指向 Thread 对象了
就只能使用 Thread.currentThread();
public class ThreadDemo17 {public static void main(String[] args) {Thread t1 = new Thread(() -> {Thread t = Thread.currentThread();System.out.println(t.getName());});Thread t2 = new Thread(() -> {Thread t = Thread.currentThread();System.out.println(t.getName());});t1.start();t2.start();}
}
2.7 休眠当前线程
也是我们⽐较熟悉⼀组⽅法,有⼀点要记得,因为线程的调度是不可控的
所以,这个⽅法只能保证实际休眠时间是⼤于等于参数设置的休眠时间的
3. 线程的状态
3.1 观察线程的所有状态
就绪:这个线程随时可以去 cpu 上执行(也包含正在 cpu 上执行)
阻塞:这个线程暂时不方便去 cpu 上执行。在java中,针对阻塞状态又做了进一步的细分
java 中,线程有以下之中状态:
• NEW: Thread 对象创建好了,但是还没有调用 start 防范在系统中创建线程
• RUNNABLE: 就绪状态,表示这个线程正在 cpu 上执行,或者准备就绪随时可以去 cpu 多行执行
• BLOCKED: 由于锁竞争,引起的阻塞
• WAITING: 不带时间的阻塞(死等),必须要满足一定的条件,才会解除阻塞。join 或者 wait 都会进入WAITING
• TIMED_WAITING: 指定时间的阻塞,到大一定时间之后自动解除阻塞。使用 sleep 会进入这个状态,使用带有超时时间的 join 也会
• TERMINATED: Thread 对象仍然纯在,但是系统内部的线程已经执行完毕了
学习线程的状态,最大的作用就是,调试多线程 bug 的时候,最为重要的参考依据
public static void main(String[] args) throws InterruptedException {Thread t = new Thread(() -> {for (int i = 0; i < 5; i++) {System.out.println("线程运行中...");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}});//线程启动之前,状态就是 NEWSystem.out.println(t.getState());t.start();t.join();//线程运行完毕之后,状态就是 TERMINATEDSystem.out.println(t.getState());}
一个 Thread 对象只能 start 一次
这和线程状态密切相关,只有处于 NEW 状态 才能 start
使用 jconsole 查看线程
如果在代码的运行中,发现某个进程卡住了,就可以使用 jconsole 这样的工具,查看这个进程中的一些重要线程的状态和调用栈
通过状态们就可以判定此线程是否阻塞,以及什么原因阻塞
4. 多线程带来的的风险 - 线程安全
引入多线程,目的是为了能够实现“并发编程”
实现“并发编程”,也不仅仅只能依靠多线程
相比之下,多线程,属于一种比较原始,也比较朴素的方案(问题和注意事项,就是比较多的)
4.1 线程安全的概念
一段代码,无论是在单线程下执行,还是多个线程下执行,都不会产生 bug,这个情况就称为“线程安全”
如果这段代码,在单线程下执行没有问题,多线程之下出现问题,这个情况就称为“线程不安全”或者“存在线程安全问题”
4.2 线程不安全的例子
public class ThreadDemo19 {private static int count = 0;public static void main(String[] args) throws InterruptedException {//创建两个线程,每个线程都针对上述 count 变量循环自增 5w 次Thread t1 = new Thread(() -> {for (int i = 0; i < 50000; i++) {count++;}});Thread t2 = new Thread(() -> {for (int i = 0; i < 50000; i++) {count++;}});t1.start();t2.start();t1.join();t2.join();//打印 count 的结果System.out.println("count = " + count);}
}
这段代码,按理说,一个代码自增 5w 次,两个线程一共自增 10w 次,最终结果应该是 10w
但是结果不符合预期,说明上述代码出现了问题,循环自增的代码,就属于“存在线程安全问题”的代码
count++ 相当于 += 1
这个count++ 其实是三个cpu指令构成的
(1)load 把内存中读取数据到 cpu 的寄存器
(2)add 把寄存器中的值 +1
(3)save 把寄存器的值歇会到内存中
如果是一个线程执行上述的三个指令,是没有问题的
如果是两个线程,并发的执行上述操作,此时就会存在变数(线程之间的顺序是不确定的)
一共有无数种情况,但是正确的情况也就只有两种
由于这两个线程是并行执行,还是并发执行也不知道,但是即使是并发执行,在一个cpu 和欣赏,两个线程有各自的上下文(各自一套寄存器的值,不会相互影响)
这种顺序,运行是正确的
这个就出现了问题
最关键的问题,在于,得确保第一个线程 save 了之后,第二个线程在 load,这个时候第二个线程 load才是第一个线程自增的后果
否则的话,第二个线程 load到的就是第一个线程自增前的结果了
4.3 线程不安全的原因
- 【根本原因】操作系统上的线程是“抢占式执行”“随机调度”
由于随机调度就会给线程之间执行的顺序带来很多变数 - 代码结构
代码中多个线程,同时修改同一个变量,会导致线程不安全
(1)一个线程修改一个变量,没有问题
(2)多个线程读取同一个变量,没有问题
说明,如果只是读取变量的内容,变量本身是固定不变的
(3)多个线程修改不同的变量,没问题
如果是两个不同的变量,彼此之间就不会产生相互覆盖的情况了
(不过这个原因不够严谨,后面会看到一个线程修改,一个线程读,也可能存在问题) - 【直接原因】上述的多线程修改操作,本身不是“原子的”
count++ 这其实是有多个 cpu 指令构成的,一个线程这行这些指令,执行到一半,就可能会被调度走,从而给其他线程“可乘之机”
每个 cpu 指令,都是“原子”的,要么不执行,要么执行完 - 内存可见性问题
- 指令重排序问题
在了解到了 导致线程不安全的原因,那么我们就可以在这些原因上,看看是否能找到解决方法
针对原因一:
无法做出任何改变,因为系统内部已经实现了 抢占式执行,无法干预
针对原因二:
分情况讨论,有的时候,代码结构可以调整,有的时候,调整不了
针对原因三:
乍看起来,count++,生成几个指令,好像无法干预
但实际上是有办法的,可以通过特殊的手段,把这三个指令打包在一起,成为“整体”
接下来,我们就细说如何让其成为“整体”
4.4 加锁 - synchronized
为了让指令打包在一起,我们可以通过“加锁”来做到
锁 具有“互斥”“排他” 这样的特征
当一个房子上了锁,外面的人就无法进入,只有当房子里面的人出来,打开锁,外面的人才有可能进入
4.4.1 用 synchronized 关键字进行加锁
在 java 中,加锁方式,有很多种,最主要的方式是 synchronized 关键字,并且任何一个对象都可以作为锁对象
加锁的目的,就是为了把多个操作,打包成一个原子的操作
在加锁的时候,需要准备好一个“锁对象”
加锁解锁操作,都是依托于这里的“锁对象”来展开
如果一个线程,针对一个对象加上锁之后
其他线程,也尝试对这个对象加锁,就会产生阻塞(BLOCKED)
一直阻塞到,前一个线程释放锁为止
我们把产生阻塞这种情况,也叫做 锁冲突/锁竞争
如果两个线程,是分别针对不同的对象加锁,其实就不会有锁竞争,就不会有堵塞
public class ThreadDemo19 {private static int count = 0;public static void main(String[] args) throws InterruptedException {Object locker = new Object();//创建两个线程,每个线程都针对上述 count 变量循环自增 5w 次Thread t1 = new Thread(() -> {for (int i = 0; i < 50000; i++) {synchronized (locker) {count++;}}});Thread t2 = new Thread(() -> {for (int i = 0; i < 50000; i++) {synchronized (locker) {count++;}}});t1.start();t2.start();t1.join();t2.join();//打印 count 的结果System.out.println("count = " + count);}
}
加上锁之后,代码就能正常执行
前面说,加锁是把 count++ 这三步操作成原子了,但是很明显,并非是加锁之后,执行三个操作工程中,线程就不调度了
但是即使加锁的线程调度走了,其他线程也无法“插队执行”
接下来,我们思考一个问题,如果我拿一个方法对 coun++ 进行封装,然后进行加锁,这样会不会是一个线程安全的呢?
class Test {public int count = 0;public void add() {count++;}
}public class ThreadDemo20 {public static void main(String[] args) throws InterruptedException {Test t = new Test();Thread t1 = new Thread(() -> {for (int i = 0; i < 50000; i++) {t.add();}});Thread t2 = new Thread(() -> {for (int i = 0; i < 50000; i++) {t.add();}});t1.start();t2.start();t1.join();t2.join();System.out.println("count = " + t.count);}
}
很明显这也是错误的,也会导致线程不安全
接下来,我们改一下代码,把 count 方法哦一个Test t 对象之,通过上述 add 方法来进行修改,加锁的时候锁对象,写作 this
class Test {public int count = 0;public void add() {synchronized (this) {count++;}}
}public class ThreadDemo20 {public static void main(String[] args) throws InterruptedException {Test t = new Test();Thread t1 = new Thread(() -> {for (int i = 0; i < 50000; i++) {t.add();}});Thread t2 = new Thread(() -> {for (int i = 0; i < 50000; i++) {t.add();}});t1.start();t2.start();t1.join();t2.join();System.out.println("count = " + t.count);}
}
这个时候,我们要看 this 值得是哪个对象,是不是对同一个对象进行加锁
synchronized (this),给 this 加锁,相当于把 synchronized 加到普通方法上
就和当时我们学的 StringBulider 和 StringBuffer 很像
但是:
那我们,如果把 this 变成 类对象,结果会如何呢?
这个时候,我们获取到 Test 的类对象,在一个 java 进程中,一个类的类对象都是只有一个
因此,第一个线程中拿到的类对象和第二个线程中拿到的类对象是同一个对象
因此锁竞争依然存在,还是可以保证线性安全的
synchronized 修饰静态方法,相当于给类对象加锁
(扩充:类对象)
4.4.2 synchronized 的特性
1、互斥性
加锁的效果,也可以称为“互斥性”
2、可重入
public class ThreadDemo21 {public static void main(String[] args) {Object locker = new Object();Thread t = new Thread(() -> {synchronized (locker) {synchronized (locker) {System.out.println("hello");}}});t.start();}
}
有的人看到两个 synchronized,会想,这样直观看起来,好像是有锁冲突的,为什么会正确运行呢?
针对 locker 进行加锁,这里的加锁应该是可以顺利获取到的
但是第二个 synchronized,直观感受上,应该是不能成功的呀!此时 locker 对象处于已经加锁的状态,这个时候,如果再尝试对 locker 加锁,不会出现“阻塞”情况吗?
为什么最终没有出现阻塞呢?
最关键的问题在于说,这两次假说,其实是在同一个线程中进行的
当前由于是同一个线程,此时锁对象,就知道了第二次加锁的线程,就是持有锁的线程,第二次操作,就可以直接通过,不会出现堵塞
这哥特性,称为“可重入”