继续学习多线程的知识~😊

一、线程安全问题
1、造成线程安全的五大原因🤨
①操作系统的随机调度(根本原因)
②两个线程同时修改同一个变量
③修改变量操作不是原子的
④内存可见性问题
⑤指令重排序问题
第①跟第②我们无法直接解决,于是往往从第③种着手
2、synchronized的特性
引入synchronized本质是通过两个线程之间产生锁竞争来确保在同一个线程下的修改操作是原子的(包括读改写三步)
另外synchronized本身还具有几个重要特性:
①互斥性
同一时刻,只能有一个线程拥有这把锁🙂
过程如下:
线程A获得锁—线程B阻塞等待—线程A释放锁—线程B获得锁
package JavaEE;
public class Demo13 {
private static int sum;
//synchronized的互斥性,线程1拿到锁线程2再申请就会阻塞
public static void main(String[] args) throws InterruptedException {
//啥类型的锁都可以
Object locke = new Object();
Thread t1 = new Thread(()->{
for (int i = 0; i < 50000; i ++) {
//只对sum加了锁,外部的还是可以并发执行
//本质是引入了锁竞争
synchronized (locke) {
//看似只有一步操作,在cpu上面有三步
//加锁的本质目的就是确保sum++的原子性,
//确保同一时刻只有一个线程对它进行操作。
sum++;
}
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
synchronized (locke) {
sum++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(sum);
}
}
②可重入性
就是在对同一个线程再次加锁时,不会产生死锁😀
package JavaEE;
public class Demo15 {
//synchronized的可重入性,使这里不会死锁
public static void main(String[] args) {
Object locke1 = new Object();
Thread t = new Thread(()->{
synchronized (locke1){
synchronized (locke1){
System.out.println("hh");
}
}
});
t.start();
}
}
3、死锁的四大必要条件(面试重点)😮
同时满足以下四种情况才会产生死锁
①互斥性(sychronized具备)
②不可被抢占(sychronized具备)
有的线程可能不会阻塞,直接把锁抢过来
③保持再请求
线程1在持有锁A的情况下,同时请求锁B
注意:1、如果通篇只有一把锁,并且具备可重入性,不会死锁
2、如果多把锁之间不进行嵌套,不会死锁
④循环等待
在满足保持再请求的条件下,等待锁的顺序出现了循环
package JavaEE;
//最简单的演示死锁方式,重点记忆一下
public class Demo14 {
public static void main(String[] args) throws InterruptedException {
Object locke1 = new Object();
Object locke2 = new Object();
Thread t1 = new Thread(()->{
//注意,这里要演示死锁的话需要嵌套,两个锁嵌套起来,
//否则就意味着在不同时间获取锁,而不是同时持有两个锁.
synchronized (locke1) {
try {
//使用sleep休眠确保t2也拿到了锁
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (locke2) {
System.out.println("t1");
}
}
});
Thread t2 = new Thread(()->{
synchronized (locke2) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (locke1) {
System.out.println("t2");
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
}
}
4、死锁的三大场景
①一个线程一把锁
可重入的锁可以解决
②两个线程两把锁
上面最近的这个示例
③M个线程N把锁
哲学家就餐问题:一个个就餐时正常,同时就餐时就会出现死锁
5、wait()与notify()方法
wait()主要起到控制线程执行顺序的作用
wait与sleep的三点区别:
1、wait使用后释放锁,sleep使用后仍持有锁
2、wait只能在synchronized中使用,sleep任何地方都可以
3、wait可以被notify唤醒,sleep必须要等休眠时间结束
package JavaEE;
import java.util.Scanner;
public class Demo17 {
public static void main(String[] args) {
Object locker = new Object();
Thread t1 = new Thread(()->{
synchronized (locker){
System.out.println("t1wait之前");
try {
//t1通过wait释放了锁,并进入等待
locker.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("t1wait之后");
}
});
Thread t2 = new Thread(()->{
synchronized (locker){
System.out.println("t2wait之前");
try {
//t2通过wait释放了锁,并进入等待
locker.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("t2wait之后");
}
});
Thread t3 = new Thread(()->{
Scanner scanner = new Scanner(System.in);
System.out.println("输入任意内容结束线程:");
scanner.next();
synchronized (locker){
//locker.notify();
locker.notifyAll();
}//一直等到出了这里,t3释放锁,其他线程继续进行
});
t1.start();
t2.start();
t3.start();
}
}
另外wait后可以设置超时时间,不会死等。notifyAll可以唤醒所有线程
6、④内存可见性问题(面试重点)😮
对于④内存可见性问题
简单说就是:一个线程修改了变量,而另一个线程看不到这个修改
(编译器对读的优化)
示例代码如下:
package JavaEE;
import java.util.Scanner;
public class Demo16 {
//发现变量在线程1中读取,线程2中修改,加上volatile就好
private static boolean flag = true;
//private static volatile bollean flag = true;
public static void main(String[] args) {
Thread t1 = new Thread(()->{
while (flag){
//Thread.sleep(1);
}
System.out.println("线程t1结束");
});
Thread t2 = new Thread(()->{
System.out.println("输入任意内容结束线程t1");
Scanner scanner = new Scanner(System.in);
scanner.next();
//修改的是工作内存中的变量,而不是主存里的
flag = false;
System.out.println("flag = " + flag);
});
t1.start();
t2.start();
}
}
它的本质原理是:
JMM(JAVA内存模型)为了提高性能,允许每个线程将主内存的变量拷贝到自己的工作内存中操作。线程直接读写工作内存,不每次都访问主内存。这就导致:一个线程修改了工作内存中的变量,但另一个线程看不到这个修改—因为修改还没同步回主内存,或者说另一个线程还在读自己工作内存中的旧值
解决办法:加入volatile修饰变量
volatile的作用就是,强制要求线程每次读取变量时必须从主内存中读取,每次修改完变量以后必须立刻写回主内存,不能使用工作变量中的缓存副本
二、单例模式
1、饿汉模式
• 特点:类加载时就创建实例
• 优点:实现简单,天生线程安全(只有读操作,没有写操作)
• 缺点:如果一直没用到,可能造成资源浪费
package JavaEE;
public class Singleton{
private static Singleton singleton = new Singleton();
//使用静态类,避免进入调用方法需要创建实例,陷入创建实例方面的死循环
//直接使用类名就可以调用该方法
public static Singleton getSingleton(){
//(只有读操作,没有写操作)
return singleton;
}
//保证私有,外部不能创建实例
private Singleton(){};
}
2、懒汉模式
• 特点:第一次调用时才创建实例
• 优点:按需加载,节省资源
• 缺点:需要处理线程安全问题(有写操作)
package JavaEE;
public class SingletonLazy {
private static volatile SingletonLazy singletonLazy = null;
public static SingletonLazy getSingletonLazy(){
if(singletonLazy == null){
//写
singletonLazy = new SingletonLazy();
}
}
//读
return singletonLazy;
}
private SingletonLazy(){};
}
3、线程安全的单例模式(面试重点)🙂
单从⑤内存重排序问题上来看
简单说就是:(编译器对写的优化)
package JavaEE;
public class SingletonLazy {
private static volatile SingletonLazy singletonLazy = null;
private static Object locker = new Object();
public static SingletonLazy getSingletonLazy(){
//第一次检查:实例是否已经创建?(不用锁,提高性能)
//作用:规避不必要的加锁操作
if(singletonLazy == null){
//加锁,引起锁竞争这样防止同一时间创建多个实例
synchronized (locker){
//第二次检查:确保同一时间只有一个实例被创建
//作用:防止多线程下一个线程创建完,另一个线程又创建
if(singletonLazy == null){
//在单例模式下,创建唯一一个SingletonLazy类的实例
//volatile关键字解决指令重排序的位置
//new操作非原子性:分配内存-初始化-赋值
singletonLazy = new SingletonLazy();
}
}
}
return singletonLazy;
}
private SingletonLazy(){};
}
它的本质原理是:
指令重排序造成的线程安全问题,DCL(双重检查锁)单例就可以作为一个典型例子。主要是因为new操作不是原子的,它包括分配内存,初始化,赋值三个步骤。在极端情况下,编译器或CPU为了优化将初始化和赋值重排序,导致另一个线程拿到一个未初始化的对象
举个例子"🌰":
1、线程A走到
singletonLazy = new SingletonLazy();,因为重排序,它先赋值了(此时instance不再为null,但指向的是一块还没初始化的内存)
2、线程B恰好这时进来检查
if(singletonLazy == null)—发现singlenLazy不为null !
3、线程B直接返回并使用这个singletonLazy,结果访问到的对象里的数据都是默认值(比如int是0,对象引用是null),程序出错
解决办法:加入volatile修饰变量
volatile的核心作用就是禁止了这种重排序,保证了初始化操作一定在赋值之前产生,从而保证所有线程看到的都是完整初始化的对象
总结:
锁(synchronized)保证了同一时间只有一个线程的实例被创建,解决了原子性问题
双重if判断减少了锁竞争,提升了性能
volatile通过禁止指令重排序,解决了其他线程看到'半成品'对象的问题
这三者各司其职,共同保证了DCL单例的安全和高效

网硕互联帮助中心

评论前必须登录!
注册