多线程 (进阶+初阶)
文章目录
- 一、进程和线程是什么
- 1.1程序
- 1.2端口号和PID
- 1.3进程和线程
- 有进程实现并发编程为什么还要使用线程?
- 两者区别(面试重点)
- 1.4串行、并行、并发
- 二、Java.lang.Thread
- 2.1第一个多线程代码
- 2.2 jconsole命令
- 2.3创建线程的四种方法(重点)
- 继承Thread类
- 实现Runnable接口
- 两种方式各自优势
- 两种创健线程方式的不同写法
- 匿名内部类继承Thread类和实现Runnable接口
- Lambda表达式
- 实现Callable接口
- 2.4并发和串行时间对比
- 2.5 练习
- 三、Thread常用方法
- 3.1构造方法
- 3.2Thread类的核心属性
- 3.3中断线程的两种方法
- 通过共享变量中断线程
- 通过方法调用中断线程
- 3.4两种线程中断差别(易错点)
- 两种中断通知
- 总结
- 3.5等待另一个线程
- join()方法延伸
- 3.6获取当前线程对象和休眠当前线程对象
- 3.7线程的状态(面试重点)
- NEW 和 RUNNABLE状态
- 三种阻塞状态(面试重点)
- yield()方法
- 四、线程安全(面试重点)
- 4.1线程不安全概念
- 4.2Java的内存模型-JMM
- 4.3线程安全的三大特性
- 原子性
- 可见性
- 可见性带来的线程安全问题
- 原子性带来的线程安全问题
- 关于内存的问题
- 指令重排
- 总结(面试重点)
- 4.4 synchronize关键字(面试重点)
- monitor lock对象锁
- mutex lock互斥(synchronize第一个特性)
- Synchronized原理
- 互斥关系的定义
- 代码块刷新内存(第二个特性)
- 上锁操作和单线程操作区别
- 可重入(第三个特性)
- 死锁(不可重入锁)
- 可重入的实际过程
- 4.5 synchronized修饰类中的成员方法
- 4.6 synchronized修饰类中的静态方法
- 4.7 synchronized修饰当前代码块
- 4.8 synchronized修饰当前class对象(难点)
- 4.9线程安全类(了解即可)
- 五、volatile关键字(面试重点)
- 5.1保证可见性
- 5.2 内存屏障
- volatile保证可见性为什么不保证原子性?
- sleep()和yield()可能刷新内存
- 六、线程间等待与唤醒机制(面试重点)
- 6.1 wait()等待
- 6.2 notify()唤醒
- 6.3等待队列 阻塞队列
- 面试题重点:sleep()和wait()的区别
- 七、单例模式(面试重点)
- 如何保证单例
- 7.1饿汉式单例
- 7.2懒汉式单例
- hashMap的懒汉式加载示例
- 7.3饿汉和懒汉的线程安全问题
- 解决懒汉式线程安全问题
- 八、阻塞队列
- 8.1生产消费者模型
- 8.2 定时器Timer(创建线程的一种方式)
- 九、线程池
- 9.1 线程池的作用
- 9.2 线程池的核心方法
- 9.3 Executors 线程池工具类
- 9.4ThreadPoolExector核心参数(面试重点)
- 单线程池的意义
- 9.5线程池工作流程
- 十、常用锁的策略
- 10.1悲观锁 乐观锁
- 10.2乐观锁的实现
- 10.3读写锁
- 10.4重量级锁和轻量级锁
- 10.5公平锁和非公平锁
- 10.6 CAS 的应用
- 10.7 synchronized 锁升级原理
- 10.8 juc包下常用子类 lock锁
- 10.8 死锁
- 十一、线程工具类 java.util.concurrent
- 11.1 Semaphore 信号量
- 11.2 CountDownLatch——大号的join()方法
- 十二、面试问题
一、进程和线程是什么
1.1程序
1.2端口号和PID
1.3进程和线程
有进程实现并发编程为什么还要使用线程?
虽然多进程也能实现并发编程, 但是线程比进程更轻量:所以线程又叫轻量级进程;
创建线程比创建进程更快;
销毁线程比销毁进程更快;
调度线程比调度进程更快;
两者区别(面试重点)
Tset就是进程,main是主线程,此时多余的一个进程java.exe是idea自己
总结:
1.4串行、并行、并发
二、Java.lang.Thread
2.1第一个多线程代码
注意事项:
1.启动线程是start方法,不是run方法。通过start方法将线程启动以后,每个线程自动执行自己的run方法
2.Thread-0这些线程名字是默认的,可以修改
3.这四个线程同时执行,互不影响
2.2 jconsole命令
2.3创建线程的四种方法(重点)
继承Thread类
继承Thread的子类就是一个线程实体
// 定义一个Thread类,相当于一个线程的模板
class MyThread01 extends Thread {// 重写run方法// run方法描述的是线程要执行的具体任务@Overridepublicvoid run() {System.out.println("hello, thread.");}
}/*** 继承Thread类并重写run方法创建一个线程* @author rose* @created 2022-06-20*/
public class Thread_demo01 {public static void main(String[] args) {// 实例化一个线程对象MyThread01 t = new MyThread01();// 真正的去申请系统线程,参与CPU调度t.start();}
}
实现Runnable接口
这个实现Runnable接口的子类,并不是真正的的线程实体,只是线程的一个核心工作任务。这是和第一种方法最大的区别
// 创建一个Runnable的实现类,并实现run方法
// Runnable主要描述的是线程的任务
class MyRunnable01 implements Runnable {@Overridepublic void run() {System.out.println("hello, thread.");}
}
/*** 通过实现Runnable接口并实现run方法* @author rose* @created 2022-06-20*/
public class Thread_demo02 {public static void main(String[] args) {// 实例化Runnable对象MyRunnable01 runnable01 = new MyRunnable01();// 实例化线程对象并绑定任务Thread t = new Thread(runnable01);// 真正的去申请系统线程参与CPU调度t.start();}
}
1.创建线程任务对象
2.创建线程对象,并传入任务对象
3.调用Thread类的start方法启动线程
两种方式各自优势
1.两种方式创建线程最后都是通过Thread类的start方法启动线程
2.继承Thread类方法创建线程属于单继承,有局限性
3.实现Runnable接口的子类更加灵活,不仅实现Runnable接口,还可以继承其他类
4.调用当前线程的区别:继承Thread类,直接使用this就表示当前线程对象的引用;实现Runnable 接口, this 表示的是 MyRunnable 的引用. 需要使用 Thread.currentThread()
两种创健线程方式的不同写法
匿名内部类继承Thread类和实现Runnable接口
/*** @author hide_on_bush* @date 2022/7/12*/
public class OtherMethod {public static void main(String[] args) {//1.匿名内部类继承Thread类Thread thread=new Thread(){@Overridepublic void run() {System.out.println("这是匿名内部类继承Thread类");System.out.println(Thread.currentThread().getName());}};//2.匿名内部类实现Runnable接口Thread runThread=new Thread(new Runnable() {@Overridepublic void run() {System.out.println("这是匿名内部类实现Runnable接口");System.out.println(Thread.currentThread().getName());}});thread.start();runThread.start();System.out.println("这是主线程"+Thread.currentThread().getName());}
}
Lambda表达式
lambda表达式是建立在函数式接口,只有一个抽象方法!!!
//3.LambadThread lambadaThread=new Thread(()-> System.out.println("这是Lambda表达式实现Runnable接口"));
实现Callable接口
- 使用线程池实现Callable接口,FutureTast是Future子类
2.4并发和串行时间对比
理论上:并发执行速度是顺序执行的一倍,所以串行耗时应该是并发的一倍
实际上:线程的创建、销毁和调用也会耗时,所以实际的时间比理论实践多一点
结论:多线程的最大好处就是调高系统处理效率
2.5 练习
正解:可能先打印1也可能先打印2,具体先调度子线程输出还是先调度主线程输出由系统决定。
至于为什么多次试验都是 21的结果:子线程位于主线程中,当t.start时主线程已经在运行,所以往往都先跑主线程才看到子线程结果
public class Thread_2533 {public static void main(String[] args) throws InterruptedException {// 记录开始时间long start = System.currentTimeMillis();// 1. 给定一个很长的数组 (长度 1000w), 通过随机数的方式生成 1-100 之间的整数.int total = 1000_0000;int [] arr = new int[total];// 构造随机数,填充数组Random random = new Random();for (int i = 0; i < total; i++) {int num = random.nextInt(100) + 1;arr[i] = num;}// 2. 实现代码, 能够创建两个线程, 对这个数组的所有元素求和.// 3. 其中线程1 计算偶数下标元素的和, 线程2 计算奇数下标元素的和.// 实例化操作类SumOperator operator = new SumOperator();// 定义具体的执行线程Thread t1 = new Thread(() -> {// 遍历数组,累加偶数下标for (int i = 0; i < total; i += 2) {operator.addEvenSum(arr[i]);}});Thread t2 = new Thread(() -> {// 遍历数组,累加奇数下标for (int i = 1; i < total; i += 2) {operator.addOddSum(arr[i]);}});// 启动线程t1.start();t2.start();// 等待线程结束t1.join();t2.join();// 记录结束时间long end = System.currentTimeMillis();// 结果System.out.println("结算结果为 = " + operator.result());System.out.println("总耗时 " + (end - start) + "ms.");}
}// 累加操作用这个类来完成
class SumOperator {long evenSum;long oddSum;public void addEvenSum (int num) {evenSum += num;}public void addOddSum (int num) {oddSum += num;}public long result() {System.out.println("偶数和:" + evenSum);System.out.println("奇数和:" + oddSum);return evenSum + oddSum;}
}
作用功能不同:
run方法的作用是描述线程具体要执行的任务;
start方法的作用是真正的去申请系统线程
运行结果不同:
run方法是一个类中的普通方法,主动调用和调用普通方法一样,会顺序执行一次;
start调用方法后, start方法内部会调用Java 本地方法(封装了对系统底层的调用)真正的启动线程,并执行run方法中的代码,run 方法执行完成后线程进入销毁阶段。
三、Thread常用方法
3.1构造方法
3.2Thread类的核心属性
name可以重复ID不可以重复
CPU调度线程,哪个线程被先调度,由操作系统决定
3.3中断线程的两种方法
线程中断是线程间通信的一种重要方式,Java中线程的启动和终止,中断Java程序员说了不算,都是系统调度的,我们所谓的中断线程只是更改线程的状态而已,要想让线程终止,run方法执行结束,自然就终止了
通过共享变量中断线程
sleep()方法属于静态方法,在哪个线程中使用,就在哪个线程中生效
通过方法调用中断线程
调用 interrupt() 方法来通知
3.4两种线程中断差别(易错点)
两种中断通知
调用静态方法查看线程是否中断:Thread.interrupted()
中断状态会被自动清除,会从true改为false
下图中使用 成员方法判断线程是否中断:Thread.currentThread().interrupted()
它的作用是当线程被中断时threa.interrupt()之后,不改变中断状态,仅仅只是查看当前线程是否中断,不做修改
总结
1.当一个线程对象调用interrupt方法后(线程变为中断状态true),用类方法Thread.interrupted判断线程,会将true改为false,但是调用成员方法isInterrupted就只是查看查看线程状态,不会修改。
2.try…catch只要捕获到中断异常,无论使用哪个判断方法(静态、成员)都会使线程中断状态从
true改为false
3.5等待另一个线程
等待一个线程也是一种线程间通信方式
加了join()方法相当于图中t1,t2,主线程三个线程变成了串行,而不是并行
join()方法延伸
t1.start();// 主线程死等t1,直到t1执行结束主线程再恢复执行t1.join();// 此时走到这里,t1线程已经执行结束,再启动t2线程t2.start();// main -> 调用t2.join() 阻塞主线程,直到t2完全执行结束再恢复主线程的执行// 主线程只等t2 2000ms - 2s,若t2在2s之内还没结束,主线程就会恢复执行t2.join(2000);// t2线程也执行结束了,继续执行主线程System.out.println("开始学习JavaEE");System.out.println(Thread.currentThread().getName());}
}
3.6获取当前线程对象和休眠当前线程对象
3.7线程的状态(面试重点)
1.New状态到Runnable状态只需要start()方法,start只是申请可以调度线程,并不能立即执行线程
2.Runnable就两个状态:一个Ready和Running,可执行状态并不一定真正在执行中ing
3.调用join()、sleep()方法都会把线程置为超时状态
4.超时等待时间到了就会还原状态:还原成ready状态,等待被系统调度
NEW 和 RUNNABLE状态
除了New和Terminated都是alive状态
三种阻塞状态(面试重点)
wait()方法一般是和notify()方法搭配使用
yield()方法
调用yield()方法的线程会从运行态转换为就绪态,主动让出CPU资源,什么时候让出CPU以及什么时候再次被CPU调度都是OS决定!!!
public class YieldTest {public static void main(String[] args) {Thread t1 = new Thread(() -> {while (true) {System.out.println(Thread.currentThread().getName());// 春鹏线程就会让出CPU,进入就绪态,等待被CPU继续调度Thread.yield();}},"春鹏线程");t1.start();Thread t2 = new Thread(() -> {while (true) {System.out.println(Thread.currentThread().getName());}},"云鹏线程");t2.start();}
}
四、线程安全(面试重点)
4.1线程不安全概念
线程不安全:在多线程场景下,串行执行和并行执行的结果不一致,就称为线程不安全。实际运行结果与我们预期不符。
比如t1和t2各自两个线程的run方法是累加5w的数值,那么最终两个线程应该相加得到10w,但是图中并发执行t1和t2线程却每次得到结果都不同。
4.2Java的内存模型-JMM
JMM内存模型和JVM内存区域划分不是一个概念,JMM只是一个概念模型并不是真正存在的,而JVM内存区域划分是实际存在的
JMM:描述线程的工作内存和主内存
JMM内存模型:
方法中的局部变量不是共享变量
4.3线程安全的三大特性
一段代码要保证线程安全问题就要满足三大特性:原子性,可见性,防止指令重排
原子性
int a=10就是一个原子性操作,要么没赋值,要么赋值成功
a+=10就是非原子性操作:先读取a的值,再执行a+10,最后将结果赋值给a,这里面包含三个原子性操作
可见性
对于共享变量的修改可以及时的被其他线程看到就叫可见性
比如图中:Counter类中的成员变量count就是共享变量,保存在堆上。可以被t1和t2线程同时访问,也就是说count这个变量保存在主内存中
此时执行increase方法可见性、原子性都不可以被保证:
count++操作:不是一个原子性操作,某个线程执行count++(原子性)这个操作时,其它线程是无法读取++后的值(可见性)
可见性带来的线程安全问题
举个例子:演示不可见性,但不一定这个数值一定是图中所说的情况造成的,只是其中的一种可能性。
最后t1先执行完毕,将自己工作内存中count=5w写回主内存中对t2线程是不可见的,于是t2就会把自己工作内存中count=55659写回工作内存并将t1写回主内存的值覆盖
原子性带来的线程安全问题
有很多种可能性,这里只是列举一种,总之最后的答案一定不是10w。
计划+2次,但最终只+了1次,本来t1已经将count=1写回了主内存,但是t2也让count=1写回了主内存,最后count的值还是1,正确操作之后应该是主内存中count=2才对。
类似火车的售票系统:
客户A在买票时,发现还有一张票,当他下订单时,在主内存中nums=1-1=0没票了,此时恰好客户B和A同时准备购买票,但是此时nums=0还没有写回主内存,导致B也买了这张票(超卖现象)。
关于内存的问题
从硬件角度来说:所谓的 “工作内存”, 则是指 CPU 的寄存器和高速缓存,比如执行a+10这个操作,第一步先从主内存中读取a变量的值,但是读取的值需要一个临时存储的地方,这个地方就叫寄存器。所谓"主内存"在硬件角度是真实存在的
CPU 访问自身寄存器的速度以及高速缓存的速度, 远远超过访问内存的速度(快了 3 - 4 个数量级,也就是几千倍,上万倍)
内存设备之所以这么多,还是出于成本考虑~
指令重排
如果是在单线程情况下,JVM、CPU指令集会对其进行优化,比如,按 1->3->2的方式执行,也是没问题,可以少跑一次前台。这种叫做指令重排序
此时t1线程实例化对象还没有将name成员变量赋值,但是t2线程调用per.getName()方法,于是就出现name=null的情况
总结(面试重点)
4.4 synchronize关键字(面试重点)
synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时,其他线程如果也执行到同一个对象 synchronized 就会阻塞等待
monitor lock对象锁
synchronized的底层是使用操作系统的mutex lock实现的
mutex lock互斥(synchronize第一个特性)
互斥:多个线程想要获取同一个对象的锁(同一个对象很关键),同一时间只有一个线程可以获取到这个锁,其它线程进入阻塞状态等待
不管几个线程对这个对象进行处理,同一时刻只有一个线程拿到这个对象的锁,拿到这个锁就会执行increase()方法,另外线程就处于等待阻塞状态,哪个线程先拿到对象的锁不确定。
Synchronized原理
每个对象都有一块描述"锁"信息的内存,这个信息告诉其它线程当前该对象有没有被哪个线程持有
当某个对象被某个线程上锁时,其它对象想要获取当前对象锁就会进入一个阻塞队列,但这个队列不遵循FIFO队列先入先出的规矩
互斥关系的定义
到底是不是构成互斥关系,关键看锁的是不是同一个对象
代码块刷新内存(第二个特性)
获取锁到释放锁的中间过程保证了原子性和可见性,当其它线程再来获取锁时,保证获取到的共享变量的值一定是正确的值
上锁操作和单线程操作区别
同一个类中,可能存在很多方法,但是只有加上关键字Synchronized的方法它是互斥的,同一时间只能被一个线程操作(类似单线程),其它没有加关键字的方法仍然是并发执行!单线程是所有方法只有一个线程在运行
可重入(第三个特性)
可重入:获取到"锁"的线程可以再次获取锁的操作
类似上了两次"锁"
死锁(不可重入锁)
public class Reentrant {private class Counter{int val;//锁的是当前Counter对象synchronized void increase(){val++;}//还是锁的当前Counter对象void increaseDouble(){//在方法内部再次调用increase方法increase();}}
}
如果increaseDouble()方法不上锁的情况:线程1就会阻塞在这里,等待自己释放锁之后才能再次进入increaseDouble()方法中的increase()方法,那么程序永远不会停止,线程1一直阻塞在这个位置,我们把这种情况叫做死锁!
可重入的实际过程
如果某个线程加锁的时候,发现锁已经被人占用,但是恰好占用的正是自己,那么仍然可以继续获取到锁, 并让计数器自增,解锁的时候计数器递减为 0 的时候,且持有线程为null才真正释放锁(才能被别的线程获取到)
关于加两次锁的原因:只要进入一个synchronized代码块一次,就得上锁一次,计数器加一
至于加不加synchronized的问题:synchronized只是保证线程安全,对于代码编译不会产生任何问题,加不加看实际应用需求,比如你只是进行读操作,完全没有必要加synchronized。只有当修改共享变量,程序员为了保证线程安全会人为在代码中自己添加关键字。
4.5 synchronized修饰类中的成员方法
在成员方法上加synchronized关键字,锁的就是该类的实例化对象
counter1和counter2是两个是两个不同的对象,在t1线程中counter1对象调用同步代码块的方法上锁的对象是counter1对象,t2线程中同理,获取的是counter2对象的锁
互斥现象:t1和t2线程使用同一个对象调用同步代码块的成员方法。哪个线程先获取对象锁就先执行哪个线程
4.6 synchronized修饰类中的静态方法
锁的是一个类,不管这个类实例化多少个对象,同一时刻也只能一个线程访问
虽然看似不同对象调用静态代码块的方法,但是同一时刻只能有一个线程能获取这个锁
4.7 synchronized修饰当前代码块
同步代码块之前的代码都可以并发执行,this相当于锁的是当前对象。三个线程中都是同一个对象调用同一个方法,所以在同一时刻只能有一个线程能进入这个方法
当不同的对象调用increase4()方法就不构成互斥了,因为此时每个线程中是不同的对象在调用同步代码块
同步代码块的意义就在于锁的粒度更细,方法中的其他代码仍然是多线程并发执行,如果锁的内容太多,多线程效率不高
4.8 synchronized修饰当前class对象(难点)
上述锁Counter.class对象锁的是Counter的同一个实例化对象,只有是Counter类同一个对象才会上锁
下图中锁的是Reentrant主类.class这个对象,class对象全局唯一,不管是Counter的哪个对象都构成互斥,同一个时刻只能一个线程进入
4.9线程安全类(了解即可)
锁的是成员方法,只要是同一个Vector对象都是互斥
ConcurrentHashMap和CopyOnWriteArrayList都属于java.util.concurrent并发工具包下的
五、volatile关键字(面试重点)
5.1保证可见性
读:直接从主内存中读取共享变量,无论当前工作内存中是否有该值
写:工作内存修改后的共享变量会立即刷新到主内存中,并且其它正在读取主内存的线程会等待,直到写操作结束
直接访问工作内存(实际是CPU的寄存器或者 CPU 的缓存),速度非常快, 但是可能出现数据不一致的情况,加上volatile,强制读写内存,速度是慢了,但是数据变的更准确了
代码示例:flag变量在没有volatile关键字修饰时,t1线程中一直处于死循环,因为 t1 线程一直读取自己的工作内存 flag=0 , t2 线程中即使改变了 flag 的值(对于t1线程来说是不可见的),t1线程也一直处于死循环,没有及时读取主内存中更新后的flag值。
但是在共享变量 flag 之前加上关键字 volatile ,线程 t1 会立即退出循环,因为 volatile 保证可见性,保证 t1 线程每次强制读取主内存中 flag 的数据
synchronized 同样可以保证可见性,去掉 flag 前的关键字volatile,给counter对象上锁,在线程 t2 中修改 flag 的值同样可以立即终止线程 t1
volatile 和 synchronized 有着本质的区别:synchronized 能够保证原子性,volatile 保证的是内存可见性不保证原子性,比如在之前写的t1线程累加5w和t2线程累加5w的例子中,即使给共享变量加上关键字volatile,同样得不到正确答案10w
5.2 内存屏障
volatile保证可见性为什么不保证原子性?
java语言是无法保证原子性的,要保证原子性只能采取上锁等方式,保证哦同一时间只有一个线程操作就能让不原子性操作变得原子性
sleep()和yield()可能刷新内存
一定保证共享变量的可见性的关键字就是synchronized关键字、volatile关键字、final关键字
此外final可以保证可见性原因:final关键字修饰的变量为常量,必须在初始化时赋值且不能更改,所以保证了天然的可见性
六、线程间等待与唤醒机制(面试重点)
等待和唤醒也是一种线程间通信的方式,Object类方法表示任意实例化对象都具有wait()和notify()方法
6.1 wait()等待
使用wait()方法会释放锁,线程会进入等待队列
带有时间参数timeout的wait方法,时间参数单位是ms
6.2 notify()唤醒
1.方法notify()也要在同步方法或同步块中调用,该方法是用来通知那些可能等待该对象的对象锁的其它线程,对其发出通知notify,并使它们重新获取该对象的对象锁。
2.如果有多个线程等待,则有线程调度器随机挑选出一个呈 wait 状态的线程。(并没有 “先来后到”)
3.在notify()方法后,当前线程不会马上释放该对象锁,要等到执行notify()方法的线程将程序执行完,也就是退出同步代码块之后才会释放对象锁。
注意事项:
1.如果没有搭配synchronized使用notify和wait方法会报错
2.唤醒等待线程也需要使用同一个对象的notify方法
调用notify方法唤醒在等待的线程,一定要等notify方法中同步代码块里执行完毕才可以执行被唤醒的线程。
修改 NotifyTask 中的 run 方法,,把 notify 替换成 notifyAll:
虽然是同时唤醒 3 个线程,,但是这 3 个线程需要竞争锁,所以并不是同时执行
6.3等待队列 阻塞队列
例如图中t3先获取到锁,所以t1和t2首先进入阻塞队列,t3调用wait方法就释放对象锁,t1和t2就去竞争这个对象锁,t3就会进入等待队列。当t1 t2 t3都进入等待队列,调用notifyall方法,则三个线程都被唤醒,但不是立即并发执行这三个线程,从等待队列同时将三个队列置入阻塞队列去竞争锁,不遵循先来先获取锁的原则
面试题重点:sleep()和wait()的区别
相同点:其实本质上两者并没有什么联系,但是可以同时让线程放弃执行一段时间
不同点:
sleep():线程Thread类提供的方法,不需要搭配关键字,并且调用方法时不需要释放锁
wait():Object类提供的方法,必须搭配synchronized关键字使用,调用方法会释放锁
七、单例模式(面试重点)
设计模式:软件开发中也有很多常见的 “问题场景”,针对这些问题场景,大佬们总结出了一些固定的套路
如何保证单例
不管外部调用或者不调用这个对象,只要类加载到JVM,唯一对象就会产生
构造方法私有化,可以保证对象产生的数量
单例对象使用静态变量static的原因:在类的内部只用一次构造方法。外部访问成员变量需要通过对象,但是外部没有这个对象,此时外部想要获取这个唯一对象就需要把这个对象设置为静态变量,获取的方式:通过get方法
7.1饿汉式单例
这个类只要一加载就产生唯一对象(饥不择食),不管外部是否需要这个对象,只要类加载到JVM,唯一对象就产生
/*** 饿汉式单例模式* @author hide_on_bush* @date 2022/7/18*/
public class SingleTon {//只产生一个对象private static SingleTon singleTon=new SingleTon();//私有化构造方法private SingleTon(){}//get方法返回唯一对象public static SingleTon getSingleTon() {return singleTon;}
}
无论是用多少个引用都是同一个对象,并且构造方法私有化,无法通过new产生实例化对象
7.2懒汉式单例
懒汉式单例更常见的原因:按需分配,只有需要实例化才产生对象,节省内存空间
hashMap的懒汉式加载示例
hashMap产生对象时都没有初始化table数组,只有第一次调用put方法才初始化数组大小,节省空间
7.3饿汉和懒汉的线程安全问题
饿汉式是天然的线程安全,JVM加载类时就创建了这个唯一对象;但是懒汉式就不一样,假设同时三个线程并行执行调用get方法,会发现对象为null,此时可能三个线程都会同时创建三个不同的对象
解决懒汉式线程安全问题
1.直接在方法上加锁:效率不高,锁的粒度太粗
2.优化 double - check
此时假设三个线程同时执行到同步代码块,当t1获取到这个锁进入同步代码块创建对象后释放锁,t2和t3还是会从开始时竞争锁的位置开始执行,还是会再次创建两个不同的对象,所以不可行
1.有三个线程,开始执行get方法,通过外层的 if (singleTon = null) 知道了实例还没
有创建的消息,于是三个线程开始竞争同一把锁
2.其中线程1率先获取到锁,此时线程1通过里层的 if (single= null) 进一步确认实例是
否已经创建,如果没创建,就把这个实例创建出来
3. 当线程1 释放锁之后,线程2 和线程3也拿到锁,也通过里层的 if (instance == null) 来
确认实例是否已经创建,发现实例已经创建出来了,就不再创建了
4.后续的线程,不必加锁,直接就通过外层 if (instance = null) 就知道实例已经创建了,从
而不再尝试获取锁了,降低了开销
3.使用volatile关键字
加锁 / 解锁是一件开销比较高的事情,而懒汉模式的线程不安全只是发生在首次创建实例的时候, 因此后续使用的时候,不必再进行加锁了,外层的 if 就是判定下看当前是否已经把 instance 实例创建出来了
volatile保证防止指令重排
加volatile关键字原因:假设此时程序中有两个线程,t1先获取锁执行同步代码块,t2刚开始执行会卡在第一个if语句,但是t1执行同步代码块会产生一个对象,那么此时t2会看到singleTon这个对象不等于null,可能会直接return 这个唯一对象,但是t1线程初始化对象还没有结束,返回的可能是一个不完整的对象,有了volatile关键字修饰才能保证JVM执行完new操作再返回对象
八、阻塞队列
和普通队列的区别:阻塞队列线程安全
内部实现原理:
8.1生产消费者模型
/*** 生产者 - 消费者模型* @author hide_on_bush* @date 2022/9/20*/
public class Consumer_Producer {public static void main(String[] args) {//阻塞队列BlockingQueue<Integer> blockingQueue=new LinkedBlockingQueue<>();Thread consumer=new Thread(()->{try {int val=blockingQueue.take();System.out.println("消费元素:"+val);} catch (InterruptedException e) {throw new RuntimeException(e);}},"消费者");Random random=new Random();Thread producer=new Thread(()->{try {int val= random.nextInt(100);blockingQueue.put(val);System.out.println("生产者放入一个元素:"+val);Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}},"生产者");consumer.start();producer.start();}
}
阻塞队列的大小一般由构造方法传入
8.2 定时器Timer(创建线程的一种方式)
标准库中提供了一个 Timer 类,Timer 类的核心方法为 schedule
schedule 包含两个参数:第一个参数指定即将要执行的任务代码,是一个new TimerTask任务,就是Runnable接口的子类;第二个参数指定多长时间之后执行 (单位为毫秒)
九、线程池
9.1 线程池的作用
- 池的作用:就是让一些对象可以被重复多次利用,并且减少频繁创建和销毁对象带来的开销问题,提高时间和空间的利用率。
- 线程池中的线程和临时创建线程的区别:
- 临时创建的线程:需要创建、启动等一系列操作
- 线程池中的线程:都是Runnable状态,可以马上拿来用,提高时间空间的利用率
9.2 线程池的核心方法
- 线程池核心类 ThreadPoolExecutor
- 线程池核心方法 submit()提交任务,参数中可以接收 Runnable 接口和 Callable 接口
9.3 Executors 线程池工具类
- Executors 本质上是 ThreadPoolExecutor 类的封装,提供了四种常用创建线程池的静态方法
- 定时器线程池使用的是 Schedule类 和其他线程池调用的类不同,调用的是schedule()方法,其它线程池调用 submit()方法
- 线程池接口关系:
9.4ThreadPoolExector核心参数(面试重点)
corePoolSize:核心池线程数量(正式工)
maximumPoolSize:线程池最大线程数量(正式工+临时工)
keepAlive:线程池临时线程允许空闲时间
workQueue:工作队列,实质上就是个阻塞队列,线程从中取出执行任务
hander:拒绝策略,当任务数量超出线程池负荷时怎么办
固定线程池:没有临时工,最大线程数量==核心池线程数量
动态缓存池:核心线程为0,每当有新任务进来都是临时创建线程
工作队列(阻塞队列)几乎用不上,最大线程数能达到40多亿
单线程池:只有一个线程,所以需要无解界限的工作队列
固定大小延迟线程池:
单线程池的意义
单独创建一个线程:执行完一次任务就需要销毁
单线程池:将任务不断提交到阻塞队列中,线程只需要不断调度工作队列中的任务即可
9.5线程池工作流程
调用submit()方法提交一个线程任务
判断当前任务数量是否大于核心池线程数量
若小于:无论当前是否有空闲线程都会创建一个新线程执行任务,而后将该线程保存到corePoolSize(招聘一个正式工)
若大于:会再次判断工作队列中是否已满判断工作队列是否已满时:
工作队列未满:将任务入队,等待线程调度
工作队列已满:判断当前线程池数量maximumSize是否已经达到最大值(正式工+临时工)判断当前maximumSize是否达到最大值:
未达到上限:创建临时线程执行此任务(临时工)
达到上限:执行拒绝政策
十、常用锁的策略
10.1悲观锁 乐观锁
- 两种锁没有优劣之分,都有使用的场景。线程冲突不严重使用乐观锁,避免多次加锁解锁操作。线程冲突严重时使用悲观锁,避免线程多次访问共享变量失败带来cpu空转,造成资源浪费
10.2乐观锁的实现
乐观锁的一个重要功能就是要检测出数据是否发生访问冲突. 我们可以引入一个 “版本号” 来解决.
乐观锁的实现案例:设当前余额为 100. 引入一个版本号 version,初始值为 1 并且我们规定 “提交版本必须大于记录当前版本才能执行更新余额”
线程 1 在自己的工作内存中减去50(100-50),线程 2 在自己的工作内存中减去20(100-20)
版本号:记录更新的次数。当线程1先将50写回主内存中时,版本号+1,表示更新了一次主内存。
线程 2 也想将自己工作内存的80写回主内存,但此时发现主内存版本号等于2,线程 2 自己的版本号等于1,两者版本号不相等,无法将80写回主内存,写入失败。不满足 “提交版本必须大于记录当前版本才能执行更新” 的乐观锁策略。就认为这次操作失败
解决策略:
1.直接报错
2.CAS策略:线程2先读取主内存版本号为2,再将主内存的新数据写回自己的工作内存进行操作(减20操作),最后将30写回主内存,并且版本号+1等于3
10.3读写锁
数据的读取一般不会发生线程安全问题,只有更新数据(CURD)的时候才可能发生线程安全问题。
JDK内置的读写锁:ReentrantReadWriteLock
读加锁和读加锁之间,不互斥
写加锁和写加锁之间,互斥
读加锁和写加锁之间,互斥
10.4重量级锁和轻量级锁
轻量级锁采用的自旋锁:获取线程失败不阻塞,不让出CPU。如果获取锁失败, 立即再尝试获取锁,无限循环,直到获取到锁为止,第一次获取锁失败, 第二次的尝试会在极短的时间内到来,一旦锁被其他线程释放,就能第一时间获取到锁
10.5公平锁和非公平锁
Synchronized锁就是典型的非公平锁
操作系统内部的线程调度就可以视为是随机的,如果不做任何额外的限制, 锁就是非公平锁,如果要想实现公平锁,就需要依赖额外的数据结构,来记录线程们的先后顺序
10.6 CAS 的应用
- CAS可以理解为乐观锁的一种实现
- 应用1: 原子类,原子类可以保证线程安全,原子类常用方法
- incrementAndGet 相当于 ++i ;getAndIncrenment 相当于 i++ ;获取原子类的值直接调用get()方法
- 内部实现原理
- 使用 CAS 机制实现自旋锁
- CAS引发的ABA问题
10.7 synchronized 锁升级原理
- Synchronized 初始使用乐观锁策略,当发现锁竞争比较频繁的时候, 就会自动切换成悲观锁策略
10.8 juc包下常用子类 lock锁
- synchronized 和 lock 的区别
10.8 死锁
十一、线程工具类 java.util.concurrent
11.1 Semaphore 信号量
- 信号量就是一个计数器,表示可用资源数量
- 信号量就类似于停车场的停车位
- 代码案例,创建信号量(可用资源数量)为 5 ,再创建 20 个线程,同一时间只有 5 个线程可以获取这 5 个资源。
- 假设创建资源数量为 6 ,如果每个线程同时获取 2 个资源,也必须同时释放 2 个资源,此时只有 3 个线程同一时刻获取这 6 个资源
11.2 CountDownLatch——大号的join()方法
- 构造 CountDownLatch 实例,初始化 10 表示有 10 个任务需要完成,每个任务执行完毕,都调用 latch.countDown()方法,同时在 CountDownLatch 内部的计数器同时自减,主线程中使用 latch.await();阻塞等待所有任务执行完毕,相当于计数器为 0 了.
十二、面试问题
- start()方法和run()方法的区别?
1、线程中的start()方法和run()方法的主要区别在于,当程序调用start()方法,将会创建一个新线程去执行run()方法中的代码。但是如果直接调用run()方法的话,会直接在当前线程中执行run()中的代码,注意,这里不会创建新线程。这样run()就像一个普通方法一样。
2、另外当一个线程启动之后,不能重复调用start(),否则会报IllegalStateException异常。但是可以重复调用run()方法。
java对象锁,synchronized
如果一个线程连续使用start()方法启动会怎么样?
线程池常见子类和常用方法
线程池核心参数、线程池关闭流程、volatile关键字保证可见性无法保证原子性
线程池工作流程
多线程 (进阶+初阶)相关推荐
- 学习笔记:C++初阶【C++入门、类和对象、C/C++内存管理、模板初阶、STL简介、string、vector、list、stack、queueu、模板进阶、C++的IO流】
文章目录 前言 一.C++入门 1. C++关键字 2.命名空间 2.1 C语言缺点之一,没办法很好地解决命名冲突问题 2.2 C++提出了一个新语法--命名空间 2.2.1 命名空间概念 2.2.2 ...
- 《看漫画学Python》1、2版分享,初阶+进阶一起学
前言 学习Python的小伙伴大部分应该都知道<看漫画学Python:有趣.有料.好玩.好用(全彩版)>这本书! 但是刚开始接触Python的朋友都会有一个共同的烦恼,自学好无聊,好枯燥, ...
- C语言初阶第三篇:换我心,为你心,始知C语言 从C语言选择语句到循环语句保姆教学来喽
换我心,为你心,始知C语言 老铁们,咱们上一篇文章把字符串的知识点给过了一遍,今天就要开始真正的程序语句的学习了,也就是选择语句和循环语句,今天的内容比较简单,所以写的也不多,废话不多说,咱们开始吧! ...
- 二叉树前中后序遍历+刷题【中】【数据结构/初阶/C语言实现】
文章目录 1. 二叉树基础操作 1.1 二叉树遍历 1.1.1 前序遍历 前序遍历(Pre-Order Traversal) 1.1.2 中序遍历 中序遍历(In-Order Traversal) 1 ...
- C语言——结构体(初阶版)
1.定义和使用结构体变量 结构体的基础知识 结构是一些值的集合,这些值称为成员变量.结构的每个成员可以是不同类型的变量. 自己建立结构体类型 结构的成员可以是标量.数组.指针,甚至是其他结构体. st ...
- 前端三剑客之 HTML - JavaEE初阶 - 细节狂魔
文章目录 前言 后端 && 前端的部分历史 - java 关于网站搭建 正文开始! HTML 怎么编写一个HTML的代码? 小拓展: 快速编写 HTML 代码的小技巧 浏览器的开发者工 ...
- 【设计经验传承】图标设计初阶要先型
UI最重要组建之一就是图标,随着扁平化设计的发展趋势,越来越注重图标的简洁与寓意表达,平面图标已占主导地位.每位设计师所处的阶段所关注的要点是不一样的,我把图标设计分为2个阶段–初阶与高阶,这样分是为 ...
- JavaEE初阶系列 -开头篇:计算机是如何工作的(为下一篇的线程做铺垫)
文章目录 前言 计算机的发展史 冯诺依曼体系 CPU 中央处理器: 进行算术运算和逻辑判断. CPU 基本工作流程 逻辑门 门电路(Gate Circuit) - 模电数电知识 1.与门:可以针对两个 ...
- MySQL初阶 - 易错知识点整理(待更新)
MySQL初阶 - 易错知识点整理(待更新) Note:这里根据 CSDN Mysql技能树 整理的易错题,可参考MySQL 有这一篇就够,MySQL详细学习教程(建议收藏),MySQL 菜鸟教程 文 ...
最新文章
- “一切都是消息”--iMSF(即时消息服务框架)之【请求-响应】模式(点对点)...
- java web问题
- [转] Windows Server 2012 Beta Cluster (Hyper-V 3.0)-iSCSI篇
- asp 判断数组等于_如何在 ASP.NET Core MVC 中处理 404 错误
- 软件测试岗位职责和划分
- 推荐系统之GBDT+LR
- linux开发板访问互联网 笔记本win10中虚拟机
- 编译原理——将代码翻译成四元式序列
- Android Studio属性动画,Android Studio 三种方式建立动画效果
- Vue2.5学习笔记(三)动画
- 中国首个数字化糖尿病逆转项目在宁波正式启动
- lwIP配置宏整理(部分)
- linux服务器安全与配置,Linux系统服务器安装后的安全配置
- 图像视频伪造检测,针对DeepFake技术检测效果不佳
- 95 后大学生利用漏洞免费吃肯德基获刑,他冤么?
- 分页的php处理,分页处理的PHP类
- 一、Linux下MySQL安装和卸载图文教程详解
- 罗斯蒙特3051差压变送器
- 只会玩VR游戏?你还可以用虚拟现实向女友求婚
- HTML中margin重合问题
热门文章
- 重学计算机网络(三) - DHCP IP的孽缘
- 在 HTML5 中捕获音频和视频
- Linux下逻辑测试语句参数和流程控制语句 if语句
- FFMPEG保存H264流到AVI文件中形成录像
- ROS中的TF坐标变换工具及实现、Rviz查看(十四)C++、python
- AST实战|手把手教你还原ob混淆:ob混淆代码特征
- alter database命令
- Content type 'multipart/form-data;boundary=--------------------------258075776767858126421870;chars
- android Retrofit下载图片
- 177本名著浓缩成了177句话!经典收藏!太有道理了!