【多线程】多线程到底是个甚——多线程初阶(复习自用)📢博客主页:🏀敲代码的布莱恩特🏀
📢欢迎点赞 👍 收藏 ⭐留言 📝 欢迎讨论!👏
📢本文由 【敲代码的布莱恩特】 原创,首发于 CSDN🙉🙉🙉
📢由于博主是在学小白一枚,难免会有错误,有任何问题欢迎评论区留言指出,感激不尽!✨
📖精品专栏(不定时更新)【JavaSE】 【Java数据结构】【LeetCode】
- 认识线程
- 线程概念
- 为啥要有线程
- 进程和线程的区别(经典面试题)
- 线程管理
- 从系统内核角度看待进程和线程
- 多线程吃鸡案例
- 多线程的缺点(这里是通俗的说,后边有详细说明)
- 第一个多线程程序
- 多线程和单线程对比
- 创建线程的几种方式
- Thread类及常见方法
- Thread 的常见构造方法:
- Thread 的几个常见属性
- 启动一个线程——start()
- 中断一个线程
- 等待一个线程——join()
- 获取当前线程的引用
- 休眠当前线程
- 线程的状态
- 观察线程的所有状态
- 线程状态和状态转移的意义
- 观察线程的状态和转移
- 多线程带来的风险——线程安全
- 观察线程不安全
- 线程不安全的原因
- 如何解决线程不安全性
- synchronized 关键字-监视器锁monitor lock
- synchronized的特性
- 互斥
- 刷新内存
- 可重入
- synchronized修饰普通方法和静态方法
- Java 标准库中的线程安全类
- volatile 关键字
- volatile 能保证内存可见性
- volatile 不保证原子性
- synchronized 也能保证内存可见性
- wait 和 notify
- wait 等待
- wait做了三件事:
- wait 的工作过程:
- 竞态条件问题
- wait 结束等待的条件:
- wait 和 sleep 的对比(高频面试题)
- notify 通知
- notifyAll( )方法 (不建议使用)
- 多线程案例
- 单例模式
- 饿汉模式
- 懒汉模式
- 思考: “饿汉模式” 和 “懒汉模式”,哪个是线程安全的???
- 如何改进 懒汉模式,让代码变成线程安全的
- 阻塞式队列
- 阻塞队列是什么
- 生产消费者模型
- 标准库中的阻塞队列
- 代码实现阻塞队列
- 定时器
- 定时器是神魔
- 标准库中的定时器
- 代码实现定时器
- 线程池
- 线程池是什么
- 为什么使用线程池??
- 线程池的组成
- 标准库中的线程池
- 实现线程池
- 总结——保证线程安全的思路
- 总结——对比进程和线程的区别
- 线程的优点
- 进程与线程的区别
所谓的"线程",可以理解成轻量级"进程",也是一种实现并发编程的方式
如果把一个进程,想象成是一个工厂,线程就是工厂中的若干个流水线
可以实现并发编程
- 单核 CPU 的发展遇到了瓶颈,要想提高算力,就需要多核 CPU,而并发编程能更充分利用多核 CPU 资源
- 有些任务场景需要 “等待 IO”,为了让等待 IO 的时间能够去做一些其他的工作,也需要用到并发编程
- 线程之间共享的资源多,完成一些 *** 作时更方便
线程比进程更轻量
- 创建线程比创建进程更快. (成本低的原因:新创建一个线程,不需要给这个线程分配很多的资源,如果新建一个进程,就需要给这个进程分配较多的资源)
- 销毁线程比销毁进程更快.
- 调度线程比调度进程更快.
-
进程是包含线程的.
每个进程至少有一个线程存在,即主线程
-
进程和进程之间不共享内存空间. 同一个进程的线程之间共享同一个内存空间.
同一个进程的多个线程之间共享的资源主要是两方面:
1.内存资源(两个不同进程之间,内存不能共享)
2.打开的文件
也有一些不是共享的资源:
1.上下文 / 状态 / 记账信息 / 优先级(每个进程要独立的参与 CPU 的调度)
2.内存中有一块特殊的区域:栈 (每个线程要独立一份) -
进程是系统分配资源的最小单位,线程是系统调度的最小单位(以PCB为单位进行调度)
实际进行并发编程的时候,多线程方式要比多进程方式更常见,也效率更高
线程管理本质上和管理进程一样,先用TCB描述,再使用 双向链表
来组织
从系统内核角度看待进程和线程TCB,(中文的全称:任务控制部件,英文的全拼;task control blocks),主要有任务的优先级,任务的栈空间的地址,以及任务栈的空间的大小,以及用来记录现在执行的任务的pc(程序计数器,当程序执行到的地方)的值。
内核只认 PCB
Linux中,内核里没有线程,只有pcb
即:一个线程和一个 PCB 对应,而一个进程可能和多个 PCB 对应
线程和代码有啥关联关系?
可以认为,一个线程就是代码中的一个执行流~~
多线程吃鸡案例执行流:按照一定的顺序来执行一组指令
思考:线程数量是越多越好吗?
不是,因为线程的调度是有开销的,随着线程数量的增多,线程调度的开销也就越大
线程数量太多,非但不会提高效率,反而会降低效率
那么:一个进程中,最多能有多少个线程呢?
- 和 CPU 个数有关
- 和线程执行的任务的类型也相关
a) CPU 密集型:程序一直在执行计算任务
b) IO 密集型:程序没咋进行计算,主要是进行输入输出 *** 作
假设这个主机有 8核 CPU (两种极端情况:)
- 若任务纯是 CPU 密集型的,此时线程的数目大概就是 8 个左右
- 若任务纯是 IO 密集型的,理论上,有多少个线程多可以
现实中的情况是要介于两者之间,实践中一般需要通过 测试 的方式来找到合适的线程数,让这个程序效率够高,同时系统的压力也不会过大
多线程的缺点(这里是通俗的说,后边有详细说明) 第一个多线程程序Java 中如何使用多线程?
标准库中提供了一个类:Thread 类
public class ThreadDemo1 {
static class MyThread extends Thread{
@Override
public void run() {
System.out.println("Hello World, 我是一个线程");
}
}
public static void main(String[] args) {
// 创建线程需要使用 Thread 类,来创建一个 Thread 的实例
// 另一方面还需要给这个线程指定 要执行哪些指令/代码
// 指定指令的方式有很多种,此处先用一种简单的,直接继承Thread类,
// 重写 Thread 类中的 run 方法
// 当 Thread 对象被创建出来的时候,内核中并没有随之产生一个线程(PCB)
Thread t = new MyThread();
// 执行这个 start 方法,才是真的创建出一个线程
// 此时内核中才随之出现了一个 PCB,这个 PCB 就会对应让 CPU 来执行该线程的代码(上面的run方法中的逻辑)
t.start();
}
}
输出结果:
代码分析:
为了进一步观察当前确实是俩线程,可以借助第三方工具
JDK 中内置了一个 jconsole
但此时并不能看到线程信息,因为当前进程已经结束了
必须要想办法让进程不要那么快结束,才能看到线程信息
直接安排一个死循环~
public class ThreadDemo1 {
static class MyThread extends Thread{
@Override
public void run() {
// super.run();
System.out.println("Hello World, 我是一个线程");
while (true){
//死循环
}
}
}
public static void main(String[] args) {
Thread t = new MyThread();
t.start();
while (true){
// 死循环
}
}
}
此时运行程序,代码跑起来之后双击 jconsole.exe
针对一个整数进行大量循环++
串行:
private static long count = 100_0000_0000L;
public static void main(String[] args) {
serial(); //串行
// concurrency(); // 并发
}
private static void serial() {
// 获取当前时间戳
long begin = System.currentTimeMillis();
int a = 0;
for (long i = 0; i < count; i++) {
a++;
}
int b = 0;
for (long i = 0; i < count; i++) {
b++;
}
long end = System.currentTimeMillis();
System.out.println("time: " + (end - begin) + "ms");
}
即:串行针对两个整数累加100亿次,大概消耗5s
System.currentTimeMillis( )
— 获取到毫秒级的时间戳
时间戳:
以 1970 年 1月1日 0时0分0秒为基准时刻,计算当前时刻和基准时刻之间的秒数 / 毫秒数 / 微秒数 之差
并发:
private static long count = 100_0000_0000L;
public static void main(String[] args) {
// serial(); //串行
concurrency(); // 并发
}
private static void concurrency() {
long begin = System.currentTimeMillis();
// 匿名内部类
Thread t1 = new Thread(){
@Override
public void run() {
int a = 0;
for (long i = 0; i < count; i++) {
a++;
}
}
};
Thread t2 = new Thread(){
@Override
public void run() {
int b = 0;
for (long i = 0; i < count; i++) {
b++;
}
}
};
// 启动线程
t1.start();
t2.start();
try {
// 线程等待,让main 线程等待 t1和t2 执行结束,然后再继续往下执行
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
// t1,t2,和 main 线程之间都是并发执行的
// 调用了 t1.start 和 t2.start之后,两个新线程正在忙着进行计算
// 此时 main线程仍然会继续执行,下面的 end 也就会被随之计算了
// 正确做法: 应该是 t1 t2计算完毕后,再来计算 end 的时间戳
long end = System.currentTimeMillis();
System.out.println("time: " + (end - begin) + "ms");
}
两个线程并发执行的时候,时间大概是 2.7s 左右,时间缩短了很多~
- 通过显示继承一个 Thread 类的方式来实现
- 通过匿名内部类的方式继承 Thread 类
- 显式创建一个类,实现 Runnable 接口;然后把 Runnable实例 关联到一个 Thread 实例上
- 通过匿名内部类的方式,实现Runnable
- 使用 lambda 表达式,来指定线程执行的内容
代码示例:
public class ThreadDemo3 {
// Runnable 本质上就是描述了 一段要执行的任务代码是啥
static class MyRunnable implements Runnable{
@Override
public void run() {
System.out.println("我是一个新线程~");
}
}
public static void main(String[] args) {
// 1.显式继承 Thread
// 2.通过匿名内部类的方式,继承Thread来创建线程
// Thread t = new Thread(){
// @Override
// public void run() {
//
// }
// };
// t.start();
// 3.显式创建一个类,实现 Runnable 接口
// 然后把 Runnable实例 关联到一个 Thread 实例上
// Thread t = new Thread(new MyRunnable());
// t.start();
// 4.通过匿名内部类的方式,实现Runnable
// Runnable runnable = new Runnable() {
// @Override
// public void run() {
// System.out.println("我是一个新线程~~");
// }
// };
// Thread t2 = new Thread(runnable);
// t2.start();
// 5.使用 lambda 表达式,来指定线程执行的内容
Thread t = new Thread(()->{
System.out.println("我是一个新线程~~~");
});
t.start();
}
}
无论是哪种方式,没有本质上的区别 (站在 *** 作系统的角度),核心都是依靠Thread类,只不过指定线程执行的任务的方式有所差异
细节上有点差别(站在代码耦合性角度
):
通过 Runnable / lambda 的方式来创建线程 和 继承 Thread 类相比,代码耦合性要更小一些,在写 Runnable / lambda 的时候 run 中没有涉及到任何 Thread 相关的内容,这就意味着,很容易把这个逻辑从多线程中剥离出来,去搭配其他的并发编程的方式来执行,当然也可以很容易的改成不并发的方式执行
方法 | 说明 |
---|---|
Thread( ) | 创建线程对象 |
Thread(Runnable target) | 使用 Runnable 对象创建线程对象 |
Thread(String name) | 创建线程对象,并命名 |
Thread(Runnable target,String name) | 使用 Runnable 对象创建线程对象,并命名 |
// Thread(String name)
public class ThreadDemo5 {
public static void main(String[] args) {
Thread t = new Thread("这是一个线程的名字,可以起的很长~"){
@Override
public void run() {
while (true){
}
}
};
t.start();
}
}
代码执行后可以用jconsole查看
Thread 的几个常见属性
属性 | 获取方法 |
---|---|
ID | getId( ) |
名称 | getName( ) |
状态 getState( ) | JVM 中的线程状态 |
优先级 | getPriority( ) |
是否后台线程 | isDaemon( ) |
是否存活 | isAlive( ) |
是否被中断 | isInterrupted( ) |
- ID 是线程的唯一标识,不同线程不会重复
- 名称是各种调试工具用到
- 状态表示线程当前所处的一个情况
- 优先级高的线程理论上来说更容易被调度到,线程的优先级高,只是获取时间片的概率大,并不代表着,优先级高的一定先执行,优先级低的后执行
- 关于后台线程,需要记住一点:JVM会在一个进程的所有非后台线程结束后,才会结束运行
- 是否存活,即简单的理解,为 run 方法是否运行结束了
- 线程的中断问题,下面我们进一步说明
代码示例:
public class Threadtest {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread("布莱恩特的线程"){
@Override
public void run() {
for (int i = 0; i < 10; i++) {
//Thread.currentThread() 获取到当前线程的实例,当前代码中,相当于 this.
System.out.println(Thread.currentThread().getName());
// 效果和上行代码一样
// System.out.println(this.getName());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// run 方法的执行过程,就代表着系统内线程的生命周期
// run 方法执行中,内核的线程就存在
// run 方法执行完毕,内核中的线程就随之销毁
System.out.println("线程要退出了");
}
};
// t.start();
// 只要线程创建完毕,下面这些属性就不变了,除非显式修改
System.out.println(t.getName());
System.out.println(t.getPriority());
System.out.println(t.isDaemon());
System.out.println(t.getId());
// 这些属性,会随着线程的运行过程而发生改变
System.out.println(t.isAlive());
System.out.println(t.isInterrupted());
System.out.println(t.getState());
t.start();
while (t.isAlive()){
System.out.println("布莱恩特的线程正在运行.....");
System.out.println(t.getState());
System.out.println(t.isInterrupted());
Thread.sleep(300);
}
}
}
启动一个线程——start()
补充:
Thread.currentThread( ),即: 获取到当前线程的实例
在当前代码中,相当于 this.
但,不是所有情况都可以使用this
注意:
若使用继承 Thread 的方式来创建线程,这个 *** 作就和 this 是一样的
若使用 Runnable 的方式或者 lambda 的方式,此时就不能使用 this
线程对象被创建出来并不意味着线程就开始运行了
调用 start 方法,才真的在 *** 作系统的底层创建出一个线程
创建实例,和重写 run 方法,是告诉线程要做什么,而调用 start 方法,才是真正开始执行
中断,就是让一个线程结束 — 结束,可能有两种情况:
①已经把任务执行完了;即:让线程 run 执行完(比较温和)
②任务执行了一半,被强制结束,即:调用线程的 interrupt 方法(比较激烈)
常见的线程中断有以下两种方式:
- 通过共享的自定义标记变量来进行沟通
private static boolean isQuit = false;
public static void main(String[] args) throws InterruptedException {
// 创建一个线程
Thread t = new Thread(){
@Override
public void run(){
while (!isQuit){
System.out.println("交易继续...");
try {
Thread.sleep(500);
}
catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("交易被终止!");
}
};
t.start();
Thread.sleep(5000);
System.out.println("发现内鬼,终止交易!");
isQuit = true;
}
上述方式的结束方式比较温和
当标记位被设置之后,等到当前这次循环执行完了之后,再结束线程
例如: 当线程执行到 sleep 的时候,已经 sleep 100ms 了,此时 isQuit 被设置为 true,当前线程不会立刻退出,而是会继续 sleep,把剩下的 400ms sleep 完,才会结束线程
- 调用 interrupt( ) 方法来通知
public static void main(String[] args) throws InterruptedException {
// 创建一个线程
Thread t = new Thread(){
@Override
public void run(){
// 此处直接使用线程内部的标记为来判定
// Thread.currentThread() 这个静态方法,获取到当前线程示例
// 哪个线程调用的这个方法,就能获取到对应的实例
while (!Thread.currentThread().isInterrupted()){
System.out.println("交易继续...");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("交易被终止!");
}
};
t.start();
Thread.sleep(1000);
System.out.println("发现内鬼,终止交易!");
t.interrupt();
}
interrupt 本质上是给该线程触发一个异常 InterruptedException,此时,线程内部就会收到这个异常,具体针对这个异常如何处理,这是 catch 内部的事情
例如,上边代码中,catch 中,只是打印了调用栈,并没有真正的结束循环,故应该再加一个 break 结束循环
如果 catch 中没有 break,相当于忽略了异常
如果有 break,则触发异常就会导致循环结束,从而线程也结束
Thread 收到通知的方式有两种:
- 如果线程因为调用 wait / join / sleep 等方法而阻塞,则以 InterruptedException 异常的形式通知,清除中断标志
- 当出现 InterruptedException 的时候,要不要结束线程取决于 catch 中代码的写法. 可以选择忽略这个异常, 也可以跳出循环结束线程.
- 否则,只是内部的一个中断标志被设置,thread 可以通过
Thread.interrupted( )
判断当前线程的中断标志被设置,清除中断标志(此方法为借助类里的静态方法判断)Thread.currentThread( ).isInterrupted( )
判断指定线程的中断标志被设置,不清除中断标志(此方法为借助实例,再拿实例里的方法进行判断),这种方式通知收到的更及时,即使线程正在 sleep 也可以马上收到
Thread.interrupted( )方式:
public static void main(String[] args) {
Thread t = new Thread(){
@Override
public void run() {
for (int i = 0; i < 10; i++) {
System.out.println(Thread.interrupted());
}
}
};
t.start();
t.interrupt();
}
Thread.currentThread( ).isInterrupted( )方式:
public static void main(String[] args) {
Thread t = new Thread(){
@Override
public void run() {
for (int i = 0; i < 10; i++) {
System.out.println(Thread.currentThread().isInterrupted());
}
}
};
t.start();
t.interrupt();
}
等待一个线程——join()
线程和线程之间是并发执行的关系,多个线程之间,谁先执行谁后执行,谁执行到哪里让出 CPU,作为程序员是完全无法感知的,是全权由系统内核负责
例如: 创建一个新线程的时候,此时接下来是主线程继续执行,还是新线程继续执行,这是不好保证的 (这也是 "抢占式执行"的重要特点 )
通过代码验证:
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(){
@Override
public void run() {
while (true){
System.out.println("我是新线程!");
try {
Thread.sleep(100);
}
catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
t.start();
while (true){
System.out.println("我是主线程!");
Thread.sleep(100);
}
}
虽然我们没办法控制哪个线程先走,哪个线程后走,但是我们可以控制,让哪个线程先结束,哪个线程后结束 — 借助线程等待
join 方法:执行 join
方法的线程就会阻塞,一直阻塞到对应线程执行结束之后,才会继续执行
存在的意义: 为了控制线程结束的先后顺序
多线程的一个场景:
例如要进行一个复杂运算,主线程把任务分成几份,每个线程计算自己的一份任务
当所有任务都被分别计算完毕后,主线程再来进行汇总(就必须保证主线程是最后执行完的线程)
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(){
@Override
public void run() {
for (int i = 0; i < 10; i++) {
System.out.println("我是线程1");
try {
Thread.sleep(1000);
}
catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
Thread t2 = new Thread(){
@Override
public void run() {
for (int i = 0; i < 10; i++) {
System.out.println("我是线程2");
try {
Thread.sleep(1000);
}
catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
t1.start();
t1.join();
t2.start();
t2.join();
}
由于 t1 的 join 放在了 t2 的 strat 之前,意味着此时还没有执行线程2,t1 这里就已经阻塞等待了,一直到 t1 结束,线程2才会继续往下,开始执行
public static Thread currentThread( )
— 返回当前线程对象的引用
代码示例:
public static void main(String[] args) {
Thread thread = Thread.currentThread();
System.out.println(thread.getName());
}
休眠当前线程
关于线程休眠:
休眠就是让当前线程进入阻塞队列
也是我们比较熟悉一组方法,有一点要记得,因为线程的调度是不可控的,所以,这个方法只能保证实际休眠时间是大于等于参数设置的休眠时间的。
方法 | 说明 |
---|---|
public static void sleep(long millis) throws InterruptedException | 休眠当前线程 millis毫秒 |
public static void sleep(long millis, int nanos) throws InterruptedException | 可以更高精度的休眠 |
线程状态和进程状态是类似的,进程状态是辅助进程进行调度,线程状态是辅助线程进行调度
public static void main(String[] args) {
for (Thread.State state : Thread.State.values()){
System.out.println(state);
}
}
状态说明:
除了 NEW 和 TERMINATED 状态外,其他4个状态的 isAlive 结果都为 true
即,isAlive:判断内核中的线程是否存在
NEW:
Thread 对象有了,内核中的线程(PCB)还没有 即:任务布置了,还没有开始执行RUNNABLE:
就绪状态,表示当前线程正在CPU上执行,或者已经准备好随时上CPU,有一个专门的就绪队列来维护- 下边这三个均表示
阻塞状态
,当前线程暂时停了下来,不会继续到CPU上执行,表示正在排队 等到时机成熟,才有机会执行
BLOCKED:
等待锁导致,这个等待在其他锁释放线程之后被唤醒
WAITING:
wait 方法导致,(死等)除非其他线程唤醒了该线程
TIMED_WAITING:
sleep 方法导致,结束时间到了就唤醒了 TERMINATED:
内核中的线程已经结束(PCB已经被销毁),但是代码中的 Thread 对象还在(这个对象得等GC来回收)
线程状态和状态转移的意义
注意:当前这几个状态都是Java的Thread类的状态,和 *** 作系统内部PCB里面的状态的值并不完全一致
yield( ):
主动放权,表示,让当前线程放弃 CPU 的执行权限,重新在就绪队列中排队。这个 *** 作相当于:sleep(0)
使用 isAlive 方法判定线程的存活状态
public static void main(String[] args) {
Thread t = new Thread(){
@Override
public void run(){
for (int i = 0; i < 1000_0000; i++) {
}
}
};
System.out.println("线程启动前: " + t.getState());
t.start();
while (t.isAlive()){
System.out.println("线程正在运行中: " + t.getState());
}
System.out.println("线程结束后: " + t.getState());
}
多线程带来的风险——线程安全
观察线程不安全线程安全这是个老生常谈的问题了,也是多线程中最核心的话题,同时是最难的话题,还是工作中最相关的话题
多线程并不好驾驭~
正因为如此,有的编程语言中,直接就把线程给干掉了(进行了诸多限制)
Python中的线程,就是“伪线程”,很多时候根本无法实现并发
JS中压根没有线程,只能通过“定时器”+回调 这样的机制凑合实现类似于“并发”的效果
Go中摒弃了线程,但是引入更高端的“协程”,借助“协程”来并发编程(比线程更高效更简单)
public class 线程安全 {
static class Counter{
public int count = 0;
public void increase(){
count++;
}
}
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread() {
@Override
public void run() {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
}
};
t1.start();
Thread t2 = new Thread() {
@Override
public void run() {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
}
};
t2.start();
t1.join();
t2.join();
// 两个线程各自自增5000次,最终预期结果,应该是10w
System.out.println(counter.count);
}
}
观察结果如下按理说应该是10w,但是为什么会出现这样的情况呢?
发生bug的原因
大概率和并发执行相关~~
由于多线程的并发执行,导致代码中出现了逻辑错误,这样的情况就称为“线程不安全”
线程是抢占式执行的 (线程不安全的万恶之源)
抢占执行:线程之间的调度完全由内核负责,用户代码中感知不到,也无法控制
线程之间谁先执行,谁后执行,谁执行到哪里从CPU上下来,这样的过程都是用户无法控制的,也是无法感知的【我们无力改变, *** 作系统内核实现的】原子性:例如自增 *** 作不是原子的
每次++,都能拆分成三个步骤:
1.把内存中的数据读取到CPU中 — load
2.在CPU中,把数据+1 — increase
3.把计算结束的数据写回到内存中 — save
当CPU执行到上边三个步骤的任意一个步骤时,都可能被调度器调度走,让给其他线程来执行,如果一个线程正在对一个变量 *** 作,中途其他线程插入进来了,如果这个 *** 作被打断了,结果就可能是错误的但是可以通过加锁的方式把这个 *** 作变成原子的~~
这点也和线程的抢占式调度密切相关. 如果线程不是 “抢占” 的, 就算没有原子性, 也问题不大.
修改共享数据:即多个线程修改同一个变量
~【这个和咱们代码的写法密切相关】
内存可见性
可见性指, 一个线程对共享变量值的修改,能够及时地被其他线程看到
Java 内存模型 (JMM): Java虚拟机规范中定义了Java内存模型.
目的是屏蔽掉各种硬件和 *** 作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果
- 线程之间的共享变量存在 主内存 (Main Memory).
- 每一个线程都有自己的 “工作内存” (Working Memory) .
- 当线程要读取一个共享变量的时候, 会先把变量从主内存拷贝到工作内存, 再从工作内存读取数据.
- 当线程要修改一个共享变量的时候, 也会先修改工作内存中的副本, 再同步回主内存.
代码顺序性
什么是代码重排序
一段代码是这样的:
1.去前台取下 U 盘
2.去教室写 10 分钟作业
3.去前台取下快递
如果是在单线程情况下,JVM、CPU指令集会对其进行优化,比如,按 1->3->2的方式执行,也是没问题,可以少跑一次前台。这种叫做 指令重排序
编译器对于指令重排序的前提是 “保持逻辑不发生变化”. 这一点在单线程环境下比较容易判断, 但
是在多线程环境下就没那么容易了, 多线程的代码执行复杂程度更高, 编译器很难在编译阶段对代
码的执行效果进行预测, 因此激进的重排序很容易导致优化后的逻辑和之前不等价.
如何解决线程不安全性重排序是一个比较复杂的话题, 涉及到 CPU 以及编译器的一些底层工作原理, 此处不做过多讨论
- 抢占式执行 ———(这个没法解决, *** 作系统内核解决)
- 自增 *** 作非原子 ——— (
这个有办法,可以给自增 *** 作加上锁
) 适用范围最广 - 多个线程同时修改同一个变量 ——— (这个不一定有办法解决,得看具体的需求)
在说synchronized之前先来了解一下什么是锁
synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待.
synchronized的功能本质上就是把“并发”变成“串行”,适当的牺牲一下速度,换来的是结果更加准确!
- 进入 synchronized 修饰的代码块, 相当于 加锁
- 退出 synchronized 修饰的代码块, 相当于 解锁
synchronized用的锁是存在 Java对象头
里的
可以粗略理解成, 每个对象在内存中存储的时候, 都存有一块内存表示当前的 “锁定” 状态
(类似于厕所的 “有人/无人”).
如果当前是 “无人” 状态, 那么就可以使用, 使用时需要设为 “有人” 状态.
如果当前是 “有人” 状态, 那么其他人无法使用, 只能排队
理解阻塞等待
刷新内存针对每一把锁, *** 作系统内部都维护了一个等待队列. 当这个锁被某个线程占有的时候, 其他线程尝
试进行加锁, 就加不上了, 就会阻塞等待, 一直等到之前的线程解锁之后, 由 *** 作系统唤醒一个新的
线程, 再来获取到这个锁.
注意
:
上一个线程解锁之后, 下一个线程并不是立即就能获取到锁. 而是要靠 *** 作系统来 “唤醒”. 这
也就是 *** 作系统线程调度的一部分工作.
假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁, 此时 B
和 C 都在阻塞队列中排队等待. 但是当 A 释放锁之后, 虽然 B 比 C 先来的, 但是 B 不一定就能
获取到锁, 而是和 C 重新竞争, 并不遵守先来后到的规则.
synchronized 的工作过程:
- 获得互斥锁
从主内存拷贝变量的最新副本到工作的内存
- 执行代码
将更改后的共享变量的值刷新到主内存
- 释放互斥锁
synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题;
理解 "把自己锁死"
一个线程没有释放锁, 然后又尝试再次加锁.
// 第一次加锁, 加锁成功
lock();
// 第二次加锁, 锁已经被占用, 阻塞等待.
lock();
按照之前对于锁的设定, 第二次加锁的时候, 就会阻塞等待. 直到第一次的锁被释放, 才能获取到第
二个锁. 但是释放第一个锁也是由该线程来完成, 结果这个线程已经躺平了, 啥都不想干了, 也就无法进行解锁 *** 作. 这时候就会 死锁.
这样的锁称为 不可重入锁
Java 中的 synchronized 是 可重入锁, 因此没有上面的问题
代码示例
在下面的代码中
- increase 和 increase2 两个方法都加了 synchronized, 此处的 synchronized 都是针对 this 当前对象加锁的.
- 在调用 increase2 的时候, 先加了一次锁, 执行到 increase 的时候, 又加了一次锁. (上个锁还没释放, 相当于连续加两次锁)
这个代码是完全没问题的. 因为 synchronized 是可重入锁.
static class Counter {
public int count = 0;
synchronized void increase() {
count++;
}
synchronized void increase2() {
increase();
}
}
在可重入锁的内部, 包含了 “线程持有者
” 和 “计数器
” 两个信息.
- 如果某个线程加锁的时候, 发现锁已经被人占用, 但是恰好占用的正是自己, 那么仍然可以继续获取到锁, 并让计数器自增.
- 解锁的时候计数器递减为 0 的时候, 才真正释放锁. (
才能被别的线程获取到
)
synchronized 本质上要修改指定对象的 “对象头”. 从使用角度来看, synchronized 也势必要搭配一个 具体的对象
来使用.
- 直接
修饰普通方法
: 锁的 SynchronizedDemo 对象
public class SynchronizedDemo {
public synchronized void methond() {
}
}
修饰静态方法
: 锁的 SynchronizedDemo 类的对象
public class SynchronizedDemo {
public synchronized static void method() {
}
}
- 修饰代码块: 明确指定锁哪个对象.
- 锁
当前对象
public class SynchronizedDemo {
public void method() {
synchronized (this) {
}
}
}
- 锁
类对象
public class SynchronizedDemo {
public void method() {
synchronized (SynchronizedDemo.class) {
}
}
}
我们重点要理解,synchronized 锁的是什么. 两个线程竞争同一把锁, 才会产生阻塞等待.
两个线程分别尝试获取两把不同的锁, 不会产生竞争.
Java 标准库中很多都是线程不安全的. 这些类可能会涉及到多线程修改共享数据, 又没有任何加锁措施.
- ArrayList
- LinkedList
- HashMap
- TreeMap
- HashSet
- TreeSet
- StringBuilder
但是还有一些是线程安全的. 使用了一些锁机制来控制.
- Vector (不推荐使用)
- HashTable (不推荐使用)
- ConcurrentHashMap
- StringBuffer
还有的虽然没有加锁, 但是不涉及 “修改”, 仍然是线程安全的
- String
代码在写入 volatile 修饰的变量的时候
- 改变线程工作内存中volatile变量副本的值
- 将改变后的副本的值从工作内存刷新到主内存
代码在读取 volatile 修饰的变量的时候
- 从主内存中读取volatile变量的最新值到线程的工作内存中
- 从工作内存中读取volatile变量的副本
通常来说,如果某个变量在一个线程中读,一个线程中写,这个时候大概率需要使用volatile
volatile这里涉及到一个重要知识点,JVM(java memory model)内存模型
例如
volatile 和 synchronized 有着本质的区别
.
synchronized 能够保证原子性, volatile 保证的是内存可见性
synchronized 既能保证原子性, 也能保证内存可见性.
都是Object
的方法,用来 协同多个线程之间的执行顺序
抢占式其实就是让两个线程执行的顺序关系充满了不确定性,让多个线程直接更好的相互配合
wait 等待 wait做了三件事:- 让当前线程阻塞等待(让这个线程的PCB从就绪队列拿到等待队列中)并准备接受通知
- 释放当前锁(要想使用wait/notify , 必须搭配synchronized,需要先获取到锁才有资格谈wait,wait 要搭配 synchronized 来使用. 脱离 synchronized 使用 wait 会直接抛出异常)
- 满足一定条件被唤醒时,重新尝试获取到这个锁
public class ThreadDemo19 {
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
System.out.println("等待前");
object.wait();
System.out.println("等待后");
}
}
预计执行结果:
此代码中,由于没有进行任何的通知机制;所以,预期效果,是一直去等待
实际执行结果:
- synchronized — 监视器锁
wait 要搭配 synchronized 来使用,脱离 synchronized 使用 wait 会直接抛出异常
- 释放锁 (得先有一个锁,才能释放)
- 等待通知 (这个过程可能很久)
- 当收到通知后,尝试重新获取锁,继续往下执行
修改之后的代码:
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
synchronized (object){
System.out.println("等待前");
object.wait();
System.out.println("等待后");
}
}
此时运行程序,就会陷入阻塞,会持续多久,不好说
竞态条件问题
如何避免 竞态条件问题??
事实上, *** 作1 和 *** 作2 在wait 上是原子的
也就是说,只要调用 wait,1 和 2 是一气呵成的,不会先后执行~
- 其他线程调用该对象的 notify 方法.
- wait 等待时间超时 (wait 方法提供一个带有 timeout 参数的版本, 来指定等待时间).
- 其他线程调用该等待线程的 interrupted 方法, 导致 wait 抛出 InterruptedException 异常.
- sleep方法:是Thread类的静态方法,当前线程将睡眠n毫秒,线程进入阻塞状态。当睡眠时间到了,会解除阻塞,进入可运行状态,等待CPU的到来。睡眠不释放锁(如果有的话)。
- wait方法:是Object的方法,必须与synchronized关键字一起使用,线程进入阻塞状态,当notify或者notifyall被调用后,会解除阻塞。但是,只有重新占用互斥锁之后才会进入可运行状态。睡眠时,会释放互斥锁。
- sleep 方法没有释放锁,而 wait 方法释放了锁 。
- sleep 通常被用于暂停执行,wait 通常被用于线程间交互/通信
- sleep() 方法执行完成后,线程会自动苏醒。或者可以使用 wait(long timeout)超时后线程会自动苏醒。wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法
notify 方法是唤醒等待的线程
- 方法notify( ) 也要在同步方法或同步块(synchronized)中调用,该方法是用来通知那些可能等待该对象的对象锁的其它线程,对其发出通知notify,并使它们重新获取该对象的对象锁
notify *** 作是一次唤醒一个线程
,如果有多个线程等待,则有线程调度器随机挑选出一个呈 wait 状态的线程
. (并没有 “先来后到”)- 在notify( ) 方法后,当前线程不会马上释放该对象锁,要等到当前执行notify( ) 方法的线程将程序执行完,也就是同步代码块执行完之后才会释放对象锁(notify本身不会释放锁)
public class ThreadDemo20 {
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(){
@Override
public void run(){
synchronized (locker){
while (true){
try {
System.out.println("wait 开始");
locker.wait(); // 要和 synchronized 对应的对象对应
System.out.println("wait 结束");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
};
t1.start();
Thread t2 = new Thread(){
@Override
public void run(){
Scanner scan = new Scanner(System.in);
System.out.println("输入任意一个整数, 继续执行notify() ");
int num = scan.nextInt();
synchronized (locker){
System.out.println("notify 开始");
locker.notify(); // notify 的对象和 wait 的对象要对应,才有效果
System.out.println("notify 结束");
}
}
};
t2.start();
}
}
画图分析
notify 方法只是唤醒某一个等待线程,使用 notifyAll 方法可以一次唤醒所有的等待线程,这些线程再去竞争同一把锁
单例模式是校招中最常考的设计模式之一.
啥是设计模式?
设计模式好比象棋中的 “棋谱”. 红方当头炮, 黑方马来跳. 针对红方的一些走法, 黑方应招的时候有
一些固定的套路. 按照套路来走局势就不会吃亏.
软件开发中也有很多常见的 “问题场景”. 针对这些问题场景, 大佬们总结出了一些固定的套路. 按照
这个套路来实现代码, 也不会吃亏.
单例模式能保证某个类在程序中只存在唯一一份实例, 而不会创建出多个实例.
这一点在很多场景上都需要. 例: MySQL JDBC中,第一步就是创建一个 DataSourse 对象,DataSourse 对象,在一个程序中只有一个实例,不应该实例化多份DataSourse 对象可以用单例模式来解决这种场景,保证指定的类只有一个实例 (若尝试创建多个实例,直接编译就会报错).
单例模式具体的实现方式, 分成 “饿汉
” 和 “懒汉
” 两种.
类加载的同时,创建实例
(只要类被加载,就会立刻实例化 Singleton 实例)
public class ThreadDemo22 {
/*
* 饿汉模式 单例实现
* "饿" —— 只要类被加载,实例就会立刻被创建 (实例创建的时机比较早)
* */
static class Singleton{
// 把构造方法变成私有的,此时在该类的外部就无法 new 这个类的实例了
private Singleton(){
}
// 再来创建一个 static 的成员,表示 Singleton 类唯一的实例
// static 成员 和类相关,和实例是无关的
// 类在内存中只有一份,static 成员也只有一份
private static Singleton instance = new Singleton();
public static Singleton getInstance(){
return instance;
}
}
public static void main(String[] args) {
// 此处 new 可以,是因为 Singleton 是 ThreadDemo22 的内部类,
// ThreadDemo 是可以访问 内部类的 private 成员的
Singleton s = new Singleton();
// 此处的 getInstance 就是获取该类实例的唯一方式,不应该使用其他方式来创建实例
Singleton s1 = Singleton.getInstance();
Singleton s2 = Singleton.getInstance();
System.out.println(s1 == s2);
}
}
懒汉模式
当类被加载的时候,不会立刻实例化
等到第一次使用这个实例的时候,再实例化
public class ThreadDemo23 {
/*
* 懒汉模式
* */
static class Singleton{
private Singleton() {
}
// 类加载的时候,没有立刻实例化
// 第一次调用 getInstance 时,才真正的实例化
private static Singleton instance = null;
public static Singleton getInstance(){
if(instance == null){
instance = new Singleton();
}
return instance;
}
}
}
类加载的时候,没有立刻实例化;第一次调用 getInstance 时,才真正的实例化
若代码,一直没有调用 getInstance,此时实例化的过程也就被省略掉了 —— 延时加载
一般认为,“懒汉模式” 比 “饿汉模式” 的效率更高~
原因: 懒汉模式有很大的可能是 “实例是用不到”,此时就节省了实例化的开销
思考: “饿汉模式” 和 “懒汉模式”,哪个是线程安全的???(线程安全:假设多个线程并发的调用 getInstance 方法,是否会导致逻辑错误)
啥样的情况会导致线程不安全???
在线程安全问题,我们提到有以下原因:
- 线程是抢占式执行的
- 修改 *** 作不是原子的
- 多个线程尝试修改同一个变量(单例模式常出现)
- 内存可见性
- 指令重排序
饿汉模式—线程安全
懒汉模式—线程不安全
分析:
-
饿汉模式:
实例化时机是在类加载的时候,而类加载只有一次机会,不可能并发执行
当多线程并发的调用 getInstance 时,
由于getInstance 里只做了一件事
:
读取 instance 实例的地址
相当于多个线程在同时读取同一个变量;因此,饿汉模式是线程安全的
-
懒汉模式:
多线程同时调用 getInstance 时,getInstance 中做了四件事
:
①读取 instance 的内容;
②判断是否为null;
③若 instance 为 null,就 new 实例;
④返回实例的地址当 new 实例的时候,就会修改 instance 的值
如果实例已经创建完毕,后续再调用getInstance,此时不涉及修改 *** 作,线程仍然是安全的
但是如果实例尚未创建,此时就可能会涉及修改~如果确实存在多个线程同时修改,就会涉及到线程安全问题!!!
懒汉模式,后续调用 getInstance 都不会触发线程安全问题,只有在第一次实例化的时候,多线程并发调用 getInstance 时,会有线程不安全问题的风险
*方法1— 加锁 synchronized
- 改法1
这样写,读取判断 *** 作,和new 修改 *** 作 仍然不是原子的,故这样修改不可行!!
private static Singleton instance = null;
public static Singleton getInstance(){
if(instance == null){
synchronized (Singleton.class){
instance = new Singleton();
}
}
return instance;
}
- 改法2
这么加是可以 保证原子性 的
private static Singleton instance = null;
public static Singleton getInstance(){
synchronized (Singleton.class){
if(instance == null){
instance = new Singleton();
}
}
return instance;
}
上述改法,虽然解决了线程不安全的问题,但仍然会问题 — 效率问题(当实例已经new好了之后,后续每次调用还会加锁,后续本来就没有安全问题,不需要加锁,再尝试加锁就多此一举了,加锁本身是一件开销比较大的 *** 作,多余的加锁会影响代码性能)
- 改法3
private static Singleton instance = null;
synchronized public static Singleton getInstance(){
if(instance == null){
instance = new Singleton();
}
return instance;
}
画图和改法2 差不多,只不过 return *** 作是在释放锁内部来完成的
由于 return 只是在读,所以这个 *** 作放到锁里边或者锁外边不影响结果
虽然改法2 和 改法3 都可行,但是改法2 的锁粒度更小,改法3 的锁粒度更大
锁的粒度: 锁中包含的代码越多,就认为锁粒度越大
一般,我们希望锁的粒度小一点更好,因为锁的粒度越大,说明这段代码的并发能力就越受限
方法2 — 双重 if
由于加锁是为了避免第一次创建实例时线程不安全,后面再进行加锁解锁 *** 作都只会降低性能,所以外层再添加 if 判断,当发现其为空时才加锁,否则直接返回已经创建好的实例对象,减少了加锁解锁的次数,从而提高性能
private static Singleton instance = null;
public static Singleton getInstance(){
if(instance == null){
synchronized (Singleton.class){
if(instance == null){
instance = new Singleton();
}
}
}
return instance;
}
第一批调用这个方法的线程
后续批次调用此方法的线程
为了改进上述可能出现的编译器优化的问题,再添加 volatile==
方法3 — volatile
private volatile static Singleton instance = null;
public static Singleton getInstance(){
if(instance == null){
synchronized (Singleton.class){
if(instance == null){
instance = new Singleton();
}
}
}
return instance;
}
懒汉模式的最终优化结果:
static class Singleton {
private Singleton() {
}
//创建static成员变量,标识Singleton类的唯一实例,为避免内存可见性问题,添加volatile
private volatile static Singleton instance = null;
public static Singleton getInstance() {
// 加锁是为了避免第一个创建实例时线程不安全,后面在进行加锁解锁 *** 作都只会降低性能
if (instance == null) {
//如果为空,说明实例还未存在(即第一次使用),则创建实例
//加锁,确保判断为空和 new对象两个 *** 作 成为原子 *** 作
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
关键点总结
1.加锁 — 保证线程安全
2.双重 if — 保证效率
3.volatile — 避免内存可见性引发的问题
以上三点缺一不可
阻塞队列是一种特殊的队列. 也遵守 “先进先出” 的原则.
阻塞队列能是一种线程安全的数据结构, 并且具有以下特性:
- 当队列满的时候, 继续入队列就会阻塞, 直到有其他线程从队列中取走元素.
- 当队列空的时候, 继续出队列也会阻塞, 直到有其他线程往队列中插入元素.
阻塞队列的一个典型应用场景就是 “生产者消费者模型
”. 这是一种非常典型的开发模型.
以包饺子为例:
(假设面和馅是备好的,且 擀面杖只有一个~)
方法1: A,B,C 三个人包饺子;这三个人分别进行 擀皮 + 包饺子 过程
该方法的锁竞争(擀面杖只有一个)太激烈
方法2: 一个人负责擀皮,另外两个负责包饺子,例:A 负责擀皮,B 和 C 负责包饺子
这就是"生产者—消费者 模型"
A: 生产者 — (生产饺子皮)
B,C:消费者 — (消费饺子皮)
此处应还有一个"交易场所":放饺子皮的东西,比如:大盘子~
阻塞队列就是生产者—消费者生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取.
- 阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力
比如在 “秒杀” 场景下, 服务器同一时刻可能会收到大量的支付请求. 如果直接处理这些支付请求,
服务器可能扛不住(每个支付请求的处理都需要比较复杂的流程). 这个时候就可以把这些请求都放
到一个阻塞队列中, 然后再由消费者线程慢慢的来处理每个支付请求.
这样做可以有效进行 “削峰”, 防止服务器被突然到来的一波请求直接冲垮.
- 阻塞队列也能使生产者和消费者之间 解耦
比如过年一家人一起包饺子. 一般都是有明确分工, 比如一个人负责擀饺子皮, 其他人负责包. 擀饺
子皮的人就是 “生产者”, 包饺子的人就是 “消费者”.
擀饺子皮的人不关心包饺子的人是谁(能包就行, 无论是手工包, 借助工具, 还是机器包), 包饺子的人
也不关心擀饺子皮的人是谁(有饺子皮就行, 无论是用擀面杖擀的, 还是拿罐头瓶擀, 还是直接从超
市买的).
阻塞队列特点:
- 若入队列 *** 作太快,队列满了,继续入队列,就会阻塞;一直阻塞到其他线程去消费队列了,才能继续入队列 (大盘子上很快就放满了饺子皮,生产者就要等待,一直等到消费者消费了一些数据(饺子皮),即:交易场所上有了空位,才能继续生产)
- 若出队列 *** 作太快,队列空了,继续出队列,也会阻塞;一直阻塞到其他线程生产了元素,才能继续出队列 (大盘子上很快就空了,饺子皮很快被"消费"完了,消费者就要等待,一直等到生产者生产了新的元素(饺子皮),消费者才能继续消费)
在 Java 标准库中内置了阻塞队列. 如果我们需要在一些程序中使用阻塞队列, 直接使用标准库中的即可.
- BlockingQueue 是一个接口. 真正实现的类是 LinkedBlockingQueue.
- put 方法用于阻塞式的入队列, take 用于阻塞式的出队列.
- BlockingQueue 也有 offer, poll, peek 等方法, 但是这些方法不带有阻塞特性.
BlockingQueue<String> queue = new LinkedBlockingQueue<>();
// 入队列
queue.put("abc");
// 出队列. 如果没有 put 直接 take, 就会阻塞.
String elem = queue.take();
代码实现阻塞队列
实现阻塞队列前,我们先思考普通队列是如何实现的??
有两种方式:
- 基于链表;
- 基于数组 — 循环队列
实现功能
- 通过 “循环队列” 的方式来实现.
- 使用 synchronized 进行加锁控制.
- put 插入元素的时候, 判定如果队列满了, 就进行 wait. (注意, 要在循环中进行 wait. 被唤醒时不一定队列就不满了, 因为同时可能是唤醒了多个线程).
- take 取出元素的时候, 判定如果队列为空, 就进行 wait. (也是循环 wait)
class BlockingQueue{
private int[] array = new int[10];
// [head,tail)
// 两者重合时,队列可能为空,也可能为满
private volatile int head = 0;
private volatile int tail = 0;
private volatile int size = 0; // 有效元素个数
/*
* 阻塞队列 入队列
* 为了和普通队列入队列区分,使用 put
* */
public void put(int value) throws InterruptedException {
synchronized (this){
// 若队列满了, 阻塞等待, 等下面的出队列 *** 作调用 notify 方法后才可继续执行
if(size == array.length){
wait();
}
array[tail] = value;
tail++;
if(tail == array.length){
tail = 0;
}
size++;
// 唤醒 出队列 *** 作
notify();
}
}
/*
* 阻塞队列 出队列
* 为了和普通队列出队列区分,使用 take
* */
public int take() throws InterruptedException {
int ret = -1;
synchronized (this){
// 若队列为空, 阻塞等待, 等到有元素入队列再开始
if(size == 0){
wait();
}
ret = array[head];
head++;
if(head == array.length){
head = 0;
}
size--;
// 唤醒 入队列 *** 作
notify();
}
return ret;
}
}
代码分析:
上述两个 wait( ) 是一定不可能被同时调用的!!
-
入队列 *** 作的 wait( )
当队列已满时,即:size == array.length;若有线程调用 put 方法,就让其执行 wait 方法,使该线程阻塞等待,直到有其他线程调用 take 方法,取出元素后,调用 notify 方法将刚才调用 put 方法产生阻塞的线程唤醒,接着继续执行 put 后续的 *** 作 -
出队列 *** 作的 wait( )
当队列为空时,即:size == 0;若有线程调用 take 方法,就让其执行 wait 方法,使该线程阻塞,直到有其他线程调用 put 方法,插入元素后,调用 notify 方法将刚才调用 take 方法产生阻塞的线程唤醒,接着继续执行 take 后续的 *** 作
测试代码:
创建两个线程,分别模拟 生产者 和 消费者
- 消费的快,生产的慢 —— 预期看到:消费者线程会阻塞等待,有生产元素后消费者才能消费
- 消费的慢,生产的快 —— 预期看到::生产者线程会阻塞等待,消费者消费元素后,生产者才能继续生产
以第二种为例
public static void main(String[] args) {
BlockingQueue blockingQueue = new BlockingQueue();
// 创建两个线程,分别模拟 生产者 和 消费者
Thread producer = new Thread(){
@Override
public void run(){
for (int i = 0; i < 10000; i++) {
try {
blockingQueue.put(i);
System.out.println("生产元素: " + i);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
producer.start();
Thread consumer = new Thread(){
@Override
public void run(){
while (true){
try {
int ret = blockingQueue.take();
System.out.println("消费元素: " + ret);
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
consumer.start();
}
部分输出结果
假设把上述代码中的 notify( ) 改成 notifyAll( ),此时会发生什么??
假设现在有三个线程,其中一个线程生产,两个线程消费,且消费速度快于生产速度
所以,两个消费者线程都触发了 wait *** 作,也就是都发生了阻塞;当我们调用 notifyAll( ) ,会将上述两个线程都唤醒,然后这两个线程都去尝试重新竞争锁
假设:
消费者1,先获取到锁,于是执行出队列 *** 作(执行完毕释放锁)
消费者2,后获取到锁,于是也会执行后续的出队列 *** 作,但是刚才生产者生产的一个元素,已经
被消费者1线程 给取走了,即,当前实际是一个空的队列,若强行执行出队列 *** 作,就会出现逻辑上的错误!!
改正方法:
将 wait( ) 方法包裹的 if 改为 while
此时两个消费者线程尝试竞争锁
消费者1,先获取到锁,wait( ) 就返回了,再次执行 while 中的条件(由于当前生产者线程生产了一个元素,size 不为0 ),循环退出,之后,消费者1 就可以执行后续出队列 *** 作,执行完毕后,释放锁
消费者2,后获取到锁,wait( ) 返回,再次执行 while 中的条件(由于刚才的消费者1 已经把生产的元素取走了,size 又是 0),循环继续执行,又一次调用 wait( ),只能继续等…
定时器 定时器是神魔定时器也是软件开发中的一个重要组件. 类似于一个 “闹钟”. 达到一个设定的时间之后, 就执行某个指定好的代码.
标准库中的定时器定时器是一种实际开发中非常常用的组件.
比如网络通信中, 如果对方 500ms 内没有返回数据, 则断开连接尝试重连.
比如一个 Map, 希望里面的某个 key 在 3s 之后过期(自动删除).
类似于这样的场景就需要用到定时器.
- 标准库中提供了一个 Timer 类. Timer 类的核心方法为 schedule .
- schedule 包含两个参数. 第一个参数指定即将要执行的任务代码, 第二个参数指定多长时间之后执行 (单位为毫秒).
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("hello");
}
}, 3000);
代码实现定时器
定时器的构成:
- 使用一个类来描述"
一段逻辑
" (一个要执行的任务 task ),同时也要记录该任务在啥时候来执行使用一个阻塞优先队列
来组织若干个任务,让队首元素是最早执行的任务,只检测队首元素是否到了时间即可
阻塞优先队列:
a.支持阻塞队列的特性
b.支持按优先级的"先进先出"
c.本质上是一个堆 - 使用优先队列的目的就是:保证队首元素是就是那个最早执行到的任务
- 用一个线程,循环扫描检测当前阻塞队列中的队首元素,若时间到,就执行指定任务
- 提供一个方法,让调用者给队列中添加任务
代码实现:
优先队列中的元素必须是可比较的
:
比较规则的指定主要有两种方式:
- 让 task 实现 Comparable 接口
- 让优先级队列在构造的时候,传入一个比较器对象(Comparator)
// 1.用一个类来描述任务
static class Task implements Comparable<Task>{
private Runnable command; // 当前任务
private long time; // 开始执行的时间
/*
* command: 当前任务
* after: 多少ms后执行,表示一个相对时间
* */
public Task(Runnable command, long after) {
this.command = command;
this.time = System.currentTimeMillis() + after;
}
// 指定任务的具体逻辑
public void run(){
command.run();
}
@Override
public int compareTo(Task o) {
//谁的时间小 谁先执行
return (int) (this.time - o.time);
}
}
Timer 实例中, 通过 PriorityBlockingQueue 来组织若干个 Task 对象.
通过 schedule 来往队列中插入一个个 Task 对象
static class Timer{
// 2.用一个阻塞优先队列来组织多干个任务,让队首元素是执行时间最早的元素
// 标准库中的阻塞优先队列
private PriorityBlockingQueue<Task> queue = new PriorityBlockingQueue<>();
public Timer(){
Worker worker = new Worker(queue);
worker.start();
}
/*
* 4.提供一个方法,让调用者添加任务
* */
public void schedule(Runnable command,long after){
Task task = new Task(command,after);
queue.put(task);
}
}
worker 线程, 一直不停的扫描队首元素, 看看是否能执行这个任务
/*
* 3.用一个线程,循环扫描检测当前阻塞队列中的队首元素,若时间到,就执行指定任务
* */
static class Worker extends Thread{
private PriorityBlockingQueue<Task> queue = null;
public Worker(PriorityBlockingQueue<Task> queue) {
this.queue = queue;
}
@Override
public void run() {
while (true){
try {
// 1.取队首元素,检查是否已到时间
Task task = queue.take();
// 2.检查当前任务是否已到时间
long curTime = System.currentTimeMillis();
if(task.time > curTime){
//时间还没到, 就把任务再 送回队列中
queue.put(task);
}
else {
// 时间到了, 直接执行
task.run();
}
} catch (InterruptedException e) {
e.printStackTrace();
// 若线程出现问题,停止循环
break;
}
}
}
}
测试代码:
public static void main(String[] args) {
Timer timer = new Timer();
timer.schedule(new Runnable() {
@Override
public void run() {
System.out.println("呵呵~");
timer.schedule(this,2000);
}
},2000);
}
代码分析:
忙等
上述代码明显存在一个严重问题:扫描线程在 “忙等”
扫描线程在循环扫描判断队首元素是否到了其发生时间,若时间一直未到,就会一直循环扫描,造成了无意义的CPU浪费
例: 早上8:30要上课,定了个8:00的闹钟,睁开眼看了下时间,发现是7:00,还有一个小时闹铃才响,再看了一眼时间,7:01,时间还没到,难道接下来一直看表嘛???每分钟看一次??每秒看一次??这样无疑是浪费精力的,且是没有意义的。这种情况就叫忙等
为了避免忙等,我们可以借助 wait( ) 来解决
在wait 和 nitify 里,我们提到了 wait( ) 的两种用法
- wait( ):死等,一直等到 notify 唤醒
- wait(time):等待是有上限的
若有 notify,就会被提前唤醒;
若无 notify,时间到了后一样会被唤醒
当扫描线程发现当前队首元素还未到指定时间时,调用 wait( )方法,使线程阻塞,减少不必要的循环扫描判断,避免了频繁占用CPU;等待时间:任务发生时间 -当前时间
BUT 若在等待的过程中,插入了其他任务时间比当前任务早执行的任务,怎么办呢?
解决方法:
1.扫描线程内部,加上wait
2.添加任务方法内部,加上notify
// 2.检查当前任务是否已到时间
long curTime = System.currentTimeMillis();
if(task.time > curTime){
//时间还没到, 就把任务再 送回队列中
queue.put(task);
synchronized (locker){
locker.wait(task.time - curTime);
}
}
/*
1. 4.提供一个方法,让调用者添加任务
2. */
public void schedule(Runnable command,long after){
Task task = new Task(command,after);
queue.put(task);
synchronized (locker){
locker.notify();
}
}
一次唤醒,两次阻塞
两种阻塞情况:
-
当队列为空时,在 take 处阻塞
当阻塞队列为空时,出现阻塞,一旦调用 schedule方法,添加了新任务,其后的 notify 方法将唤醒这个线程 -
若队列非空,时机还没到,就在wait 处阻塞
①插入的任务早于当前队首任务时间,这时队首元素将变为新的任务,再次执行之后的判断即可
②插入的任务等于或晚于当前队首任务时间,扫描线程继续阻塞
附最终全部代码:
/*
* 定时器
* */
public class ThreadDemo26 {
// 1.用一个类来描述任务
static class Task implements Comparable<Task>{
private Runnable command; // 当前任务
private long time; // 开始执行的时间
/*
* command: 当前任务
* after: 多少ms后执行,表示一个相对时间
* */
public Task(Runnable command, long after) {
this.command = command;
this.time = System.currentTimeMillis() + after;
}
// 指定任务的具体逻辑
public void run(){
command.run();
}
@Override
public int compareTo(Task o) {
//谁的时间小 谁先执行
return (int) (this.time - o.time);
}
}
static class Timer{
// 为了避免忙等,需要使用wait 方法,使用一个单独的对象,来辅助进行wait
private Object locker = new Object();
// 2.用一个阻塞优先队列来组织多干个任务,让队首元素是执行时间最早的元素
// 标准库中的阻塞优先队列
private PriorityBlockingQueue<Task> queue = new PriorityBlockingQueue<>();
public Timer(){
Worker worker = new Worker(queue,locker);
worker.start();
}
/*
* 4.提供一个方法,让调用者添加任务
* */
public void schedule(Runnable command,long after){
Task task = new Task(command,after);
queue.put(task);
synchronized (locker){
locker.notify();
}
}
}
/*
* 3.用一个线程,循环扫描检测当前阻塞队列中的队首元素,若时间到,就执行指定任务
* */
static class Worker extends Thread{
private PriorityBlockingQueue<Task> queue = null;
private Object locker = null;
public Worker(PriorityBlockingQueue<Task> queue,Object locker) {
this.queue = queue;
this.locker = locker;
}
@Override
public void run() {
while (true){
try {
// 1.取队首元素,检查是否已到时间
Task task = queue.take();
// 2.检查当前任务是否已到时间
long curTime = System.currentTimeMillis();
if(task.time > curTime){
//时间还没到, 就把任务再 送回队列中
queue.put(task);
synchronized (locker){
locker.wait(task.time - curTime);
}
}
else {
// 时间到了, 直接执行
task.run();
}
} catch (InterruptedException e) {
e.printStackTrace();
// 若线程出现问题,停止循环
break;
}
}
}
}
}
线程池
线程池是什么
在String内部,为了进行一些优化,引入了一个"字符串常量池"
线程池是一种多线程处理形式,处理过程中可以 将任务添加到队列中
,然后 再创建线程后自动启动这些任务
使用线程池最大的原因是可以根据系统的需求和硬件环境灵活的控制线程的数量,可以对所有线程进行统一的管理和控制,从而提高系统运行效率,避免了频繁创建 / 销毁线程的开销
需要管理两个内容:①要执行的任务,②执行任务的线程们
- 先有一个类,表示工作线程
- 还需要一个类,用来表示具体的工作内容 (借助Runnable 接口)
- 还需要一个数据结构来组织若干个任务 (BlockingQueue)
- 还需要一个数据结构来组织若干个线程 (List)
- 使用 Executors.newFixedThreadPool(10) 能创建出固定包含 10 个线程的线程池.
- 返回值类型为 ExecutorService
- 通过 ExecutorService.submit 可以注册一个任务到线程池中.
ExecutorService pool = Executors.newFixedThreadPool(10);
pool.submit(new Runnable() {
@Override
public void run() {
System.out.println("hello");
}
});
Executors 创建线程池的几种方式
(Executors 本质上是 ThreadPoolExecutor 类的封装)
- newFixedThreadPool: 创建固定线程数的线程池
- newCachedThreadPool: 创建线程数目动态增长的线程池.
- newSingleThreadExecutor: 创建只包含单个线程的线程池.
- newScheduledThreadPool: 设定 延迟时间后执行命令,或者定期执行命令. 是进阶版的 Timer.
- 核心 *** 作为 submit, 将任务加入线程池中
- 使用 Worker 类描述一个工作线程. 使用 Runnable 描述一个任务.
- 使用一个 BlockingQueue 组织所有的任务
- 每个 worker 线程要做的事情: 不停的从 BlockingQueue 中取任务并执行.
- 指定一下线程池中的最大线程数 maxWorkerCount; 当当前线程数超过这个最大值时, 就不再新增线程了.
线程池 核心 *** 作
execute
把一个任务加到线程池中
public void execute(Runnable command) throws InterruptedException {
// 使用 延时加载 的方式来创建线程
// 当线程池中的线程数量少于阈值,则创建新线程执行该任务
// 否则添加进队列,等待其他线程结束之前的任务再执行该任务
if(pool.size() < MAX_WORKER_COUNT){
// 创建新线程
Worker worker = new Worker(queue);
// 执行任务
worker.start();
// 将 worker 添加到 线程池中
pool.add(worker);
}
queue.put(command);
}
shutdown
销毁线程池中的所有线程
调用每个线程的interrupt方法,使线程中断;调用 interrupt 后,每个线程并不是立即结束,而是可能等待一段时间,所以需要再使用 join 方法,来等待每个线程都执行结束
第一个循环触发异常,终止线程
第二个是等待每个线程结束
public void shutdown() throws InterruptedException {
// 终止掉所有的线程
for(Worker worker : pool){
worker.interrupt();
}
// interrupt后,每个线程不是立刻结束
// 需等待每个线程执行结束
for (Worker worker : pool){
worker.join();
}
}
完整实现代码:
public class ThreadDemo27 {
// 使用这个类描述 当前的工作线程
static class Worker extends Thread{
// 每个 worker 线程都需要从任务队列中取任务
// 需要能够获取到任务队列中的实例
private BlockingQueue<Runnable> queue = null;
public Worker(BlockingQueue<Runnable> queue) {
this.queue = queue;
}
@Override
public void run(){
// 此处 while被try 包裹: 只要线程收到异常,就会立刻结束 run 方法(结束线程)
try {
while (!Thread.currentThread().isInterrupted()){
Runnable command = queue.take();
command.run();
}
}
catch (InterruptedException e){
// 线程被结束
System.out.println(Thread.currentThread().getName() + " 线程结束");
}
}
}
static class MyThreadPool{
//线程池中最多可同时执行的线程数量
private static final int MAX_WORKER_COUNT = 5; // 一个线程内部应该有多少个线程,应该根据实际情况来定
// 阻塞队列: 用于组织若干个任务
private BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>();
// List: 用于组织若干个工作线程
private List<Worker> pool = new ArrayList<>();
/*
* 实现 execute 方法:
*/
public void execute(Runnable command) throws InterruptedException {
// 使用 延时加载 的方式来创建线程
// 当线程池中的线程数量少于阈值,则创建新线程执行该任务,否则添加进队列,等待其他线程结束之前的任务再执行该任务
if(pool.size() < MAX_WORKER_COUNT){
// 创建新线程
Worker worker = new Worker(queue);
// 执行任务
worker.start();
// 将 worker 添加到 线程池中
pool.add(worker);
}
queue.put(command);
}
/*
* 实现 shutdown 方法:
*/
public void shutdown() throws InterruptedException {
// 终止掉所有的线程
for(Worker worker : pool){
worker.interrupt();
}
// interrupt后,每个线程不是立刻结束
// 需等待每个线程执行结束
for (Worker worker : pool){
worker.join();
}
}
}
}
线程池: 本质上是一个生产者—消费者模型
生产者: 调用 execute 的代码就是生产者,生产者成产了任务 (Runnable 对象)
消费者: Woker 线程 就是消费者,消费了队列中的任务
交易场所: BlockingQueue
总结——保证线程安全的思路当最初创建线程池实例的时候,此时线程池中没有线程
- MyThreadPool pool = new MyThreadPool();
继续调用 execute,就会触发创建线程 *** 作
- pool.execute(new Command(i));
- 使用没有共享资源的模型
- 适用共享资源只读,不写的模型
1.不需要写共享资源的模型
2.使用不可变对象 - 直面线程安全(重点)
1.保证原子性
2.保证顺序性
3.保证可见性
4.抢占式执行(这个我们无力改变,这是 *** 作系统内核实现的
)
- 创建一个新线程的代价要比创建一个新进程小得多
- 与进程之间的切换相比,线程之间的切换需要 *** 作系统做的工作要少很多
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的可并行数量
- 在等待慢速I/O *** 作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
- I/O密集型应用,为了提高性能,将I/O *** 作重叠。线程可以同时等待不同的I/O *** 作。
- 进程是系统进行资源分配和调度的一个独立单位,线程是程序执行的最小单位。
- 进程有自己的内存地址空间,线程只独享指令流执行的必要资源,如寄存器和栈。
- 由于同一进程的各线程间共享内存和文件资源,可以不通过内核进行直接通信。
- 线程的创建、切换及终止效率更高。
欢迎分享,转载请注明来源:内存溢出
评论列表(0条)