需要了解线程知识的可以看我这几篇博客:
目录
一、线程安全的概念
如果多线程环境下运行的代码的结果是符合我们预期的,并且在单线程和多线程执行的结果都相同,则说这个程序是线程安全的。
二、多线程带来的风险(重点)
我们用两个线程同时去执行count相加的工作,希望能够得出1万
private static int count; public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(() -> { for (int i = 0; i < 5000; i++) { count++; } }); Thread t2 = new Thread(() -> { for (int i = 0; i < 5000; i++) { count++; } }); //创建线程并执行 t1.start(); t2.start(); //主线程等待一下t1 t2线程 t1.join(); t2.join(); System.out.println(count); }
结果:
我们发现跟预期完全不一样,而且差很多,我们再运行几次试试。
发现每次运行的结果都不一样,而且这太可怕了。
如果我们是在银行上班,这如果是客人的钱,哪谁还敢在我们银行存钱?
原因:
此处代码中的count++操作,其实在cpu视角看来,是3个指令
(1)load:把内存中的数据,读取到cpu寄存器里
(2)add:把cpu寄存器里的数据 + 1
(3)save:把寄存器的值,写回内存
但是不同cpu指令集可能不同,这里只是举例理解:
三、线程不安全的原因
1.线程是随机调度的
线程是随机调度的这是线程安全问题的 罪魁祸⾸随机调度使⼀个程序在多线程环境下, 执⾏顺序存在很多的变数.程序猿必须保证在任意执⾏顺序下 , 代码都能正常⼯作
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while (true) {
System.out.println("hello t1");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
Thread t2 = new Thread(() -> {
while (true) {
System.out.println("hello t2");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
Thread t3 = new Thread(() -> {
while (true) {
System.out.println("hello t3");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t1.start();
t2.start();
t3.start();
t1.join();
t2.join();
t3.join();
}
而且这是操作内核的操作,我们做为应用层的程序员是无法干预的。
2.多个线程,同时修改同一变量
这个在线程不安全的原因已经详细解释过了。我们依然无法做到同一时间修改同一变量。
3.修改操作不是原子的
例如刚刚的count++;就不是原子的背后是load add save 三个指令,包括 +=、-=、/=、*=……
我们把⼀段代码想象成⼀个房间,每个线程就是要进⼊这个房间的⼈。如果没有任何机制保证,A进⼊房间之后,还没有出来;B 是不是也可以进⼊房间,打断 A 在房间⾥的隐私。这个就是不具备原⼦性的。
那我们应该如何解决这个问题呢?是不是只要给房间加⼀把锁,A 进去就把⻔锁上,其他⼈是不是就进不来了。这样就保证了这段代码的原⼦性了。有时也把这个现象叫做同步互斥,表⽰操作是互相排斥的。
java就提供了一个关键字用来加锁
3.1 synchronized
java中提供了synchronized()关键字,来完成加锁操作。
虽然synchronized关键词带有括号但它并不是方法,( ) 里需要指定一个锁对象,来进行后续的判断。()里可以是如何类型的对象。
我们用上面的count++的例子来理解:
private static int count;
private static Object locker = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
synchronized (locker) {
count++;
}
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
synchronized (locker) {
count++;
}
}
});
//创建线程并执行
t1.start();
t2.start();
//主线程等待一下t1 t2线程
t1.join();
t2.join();
System.out.println(count);
}
结果:
为什么这次正确了?
我们在非原子操作语句中加上了synchronized
在指令层面的原理:
(1)加锁会有一个lock指令
(2)出锁会有一个unlock指令
这样就避免了count++的指令被拆分了。
锁对象的作用,就是来区分,多个线程是否是针对 “同一对象” 加锁:
- 如果是针对同一个对象加锁,此时就会出现“阻塞”(锁竞争/锁冲突)
- 不是针对同一个对象加锁,此时就不会出现“阻塞”,多个线程仍然是并发执行的
不知道看了上述的描述大家是不是觉得这跟串行执行有什么区别?
其实我们只是在少量代码中(同一时间要改变同一变量的值)的逻辑变为了“串行执行”,多数逻辑中仍然是并行执行的,所以仍然是比单线程快很多的。
3.2 synchronized的多种写法
3.2.1任意对象
synchronized (locker) {
count++;
}
3.2.2 类对象
synchronized (Demo7.class) {
count++;
}
编写的java文件就是.java的文件,通过javac编译成.class文件。jvm运行的时候,把class文件加载到内存中,形成了对应的类对象。一个java进程中,一个类的类对象是只有一个的。
类对象也是对象当然也就可以写在synchronized()里面了。
3.2.3 synchronized修饰普通方法
private static int count;
private static Object locker = new Object();
synchronized private void add() {
count++;
}
public static void main(String[] args) throws InterruptedException {
Demo7 counts = new Demo7();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
counts.add();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
counts.add();
}
});
//创建线程并执行
t1.start();
t2.start();
//主线程等待一下t1 t2线程
t1.join();
t2.join();
System.out.println(count);
}
结果:
这样的写法类似于:
private void add() {
synchronized (this) {
count++;
}
}
3.2.4 synchronized修饰静态方法
synchronized private static void add() {
count++;
}
静态方法没有this,那它就类似于:
private static void add() {
synchronized (Demo7.class) {
count++;
}
}
3.3 可重入锁
void func() { synchronized(this) { //这个锁会怎么样? synchronized(this) { } } }
思考一下,当我们进入第一个锁的时候,再次进入另一个锁是否会发生阻塞?
还是之前的count++例子:
public static void main(String[] args) throws InterruptedException { Object locker = new Object(); Thread t1 = new Thread(() -> { for (int i = 0; i <= 5000; i++) { synchronized (locker) { synchronized (locker) { count++; } } } }); Thread t2 = new Thread(() -> { for (int i = 0; i <= 5000; i++) { synchronized (locker) { synchronized (locker) { count++; } } } }); //创建线程并执行 t1.start(); t2.start(); //主线程等待一下t1 t2线程 t1.join(); t2.join(); System.out.println(count); }
结果:
我们发现还是可以输出结果并且是正确的
java引入了可重入锁,同一线程可以入锁多次,为了避免不小心写了多个锁的情况:
- 允许同一线程多次获取锁
- 基于计算器的重入机制
- 避免自我阻塞与死锁
3.4 死锁
大家可以看这样一段代码:
public static void main(String[] args) throws InterruptedException { Object locker1 = new Object(); Object locker2 = new Object(); Thread t1 = new Thread(() -> { synchronized (locker1) { System.out.println("t1 对locker1 加锁完成"); synchronized (locker2) { System.out.println("t1 对locker2 加锁完成"); } } }); Thread t2 = new Thread(() -> { synchronized (locker2) { System.out.println("t2 对locker2 加锁完成"); synchronized (locker1) { System.out.println("t1 对locker1 加锁完成"); } } }); t1.start(); t2.start(); t1.join(); t2.join(); }
结果:
程序一直没有结束,进入了阻塞:
我们可以通过jconsole观察:
3.4.1 死锁的必要条件
- 锁是互斥的【锁的基本特性】
- 锁是不可被抢占的。线程1拿到了锁A,如果线程1不主动释放A,线程2不能把锁抢过来【锁的基本特性】
- 请求和保持。线程1,拿到锁A之后,不释放A的前提下,去拿锁B【代码结构】
- 循环等待/环路等待/循环依赖。多个线程获取锁的过程,存在循环等待,刚刚举例的就是【代码结构】
3.4.2 死锁场景 经典哲学家就餐问题
有5个哲学家,5根筷子吃同一碗面,需要拿起两根筷子才可以吃面,但是哲学家又非常固执,只要拿起了筷子,没吃着面绝对不会放下筷子
如果我们不进行干预,他们都拿到了一根筷子,那谁都吃不到,谁都不会放下【阻塞了】
那我们可以给哲学家进行一个编号,编号大的一个可以先拿起右手边的筷子,拿了右手的筷子才可以去拿左手边的筷子:
第一次5号哲学家拿起了两根筷子,先吃了面并且放下筷子。
此时1号哲学家就可以拿起右手的筷子,4号哲学家可以拿起左手的筷子,依次循环
每个哲学家都对应着一个线程
4.可见性
内存可见性问题,本质上,是编译器/JVM对代码的优化的时候,优化出BUG。如果是单线程的,编译器/JVM,代码优化一般都是非常准确的,优化之后,不会影响到逻辑。但如果是多线程的可能就会出现一些问题。
public static int n = 0; public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(() -> { //只要不为0就结束循环 while (n == 0) { //什么都不写 } System.out.println("t1 线程已结束"); }); Thread t2 = new Thread(() -> { Scanner sc = new Scanner(System.in); System.out.println("请输入一个整数"); n = sc.nextInt(); }); t1.start(); t2.start(); t1.join(); t2.join(); }
我们想用 t2 线程来控制 t1 线程是否终止。
但是我们来看结果:
逻辑上没有问题,那为什么t1线程没有结束?
此时JVM执行这个操作发现,每次执行(1)的操作开销都非常大(相比(2)来说),而且每一次比较都一样呀~,并且JVM并没有并没有意识到未来可能会改变n的值,这个时候JVM就做了一个大胆的决定,它把(1)操作给优化掉了,每次只和寄存器上的数据进行比较。
但是t2线程修改n的值放到内存中,但是此时t1每次循环,不会真的读取内存中的值,此时对于t1来说,n的改变是“不可变的”。
4.1 如何解决可见性问题
4.1.1 加开销稍大的操作
public static int n = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
//只要不为0就结束循环
while (n == 0) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("t1 线程已结束");
});
Thread t2 = new Thread(() -> {
Scanner sc = new Scanner(System.in);
System.out.println("请输入一个整数");
n = sc.nextInt();
});
t1.start();
t2.start();
t1.join();
t2.join();
}
结果:
为什么这个时候没有优化了?
将内存上的内容读取到寄存器的操作,相比之下远远小于sleep,那这个时候再进行优化就杯水车薪了。
很明显这不是一个好的办法,有的时候对程序的速度要求很高,因为这个问题就浪费了100ms,就太不值得了
4.1.2 volatile
加上volatile关键字,就是在提示编译器,表示这个变量是“易变”的
public static volatile int n = 0;
引入了volatile的时候,编译器生成这个代码的时候,就会在读取这个变量的操作附件生成一些特殊的指令,称为“内存屏障”。后续JVM执行到这些特殊的指令,就知道了,不能进行上述优化
public static volatile int n = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
//只要不为0就结束循环
while (n == 0) {
//什么都不写
}
System.out.println("t1 线程已结束");
});
Thread t2 = new Thread(() -> {
Scanner sc = new Scanner(System.in);
System.out.println("请输入一个整数");
n = sc.nextInt();
});
t1.start();
t2.start();
t1.join();
t2.join();
}
结果:
5.指令重排序
在此之前我想给大家介绍一种设计模式
《单例设计模式》是用于一些类特别大,例如:100G,一个程序只支持实例化一个对象的类。那么这个时候我们设计类的时候就可以采用《单例模式》。
《单例设计模式》我们主要介绍一下主要的两种:
1.饿汉设计模式 2.懒汉设计模式
5.1 饿汉设计模式
“饿” 就代表着急迫的意思,只要程序一加载,就会创建实例
//单列模式中的:饿汉模式 class Singleton { //将实例写为static,程序一启动就会创建实例 private static Singleton instance = new Singleton(); //每次通过getInstance来获取对象 public static Singleton getInstance() { return instance; } //将构造方法设为私有的,就不能通过new 关键字实例化对象了 private Singleton() { } } public class demo18 { public static void main(String[] args) { //此时编译器就报错了 Singleton singleton = new Singleton(); //只能通过这个方法获取实例对象 Singleton singleton1 = Singleton.getInstance(); } }
此时就会提醒你说这个方法是所有的
5.2 懒汉模式
“懒” 在计算机中是褒义词,代表效率高,它往往代表着,加载数据只加载目前需要的一部分,不会一下加载全部数据,那么就代表着效率高。
//单列模式中:懒汉模式 class SingletonLazy { private static SingletonLazy instance = null; //私有的构造方法,保证外部无法实例对象 private SingletonLazy() { }; private static SingletonLazy getInstance() { //instance是否为null if (instance == null) { instance = new SingletonLazy(); } return instance; } }
当你需要这个对象的时候才开始创建。目前我们只是用懒汉设计模式的思想实现了一小部分
5.3 这两种设计模式是否是线程安全的
观察一下这两种设计模式是否线程安全?
因为:
5.4 解决线程不安全问题
1.那更具上述的描述,我们可以直接用synchronized解决
//单列模式中:懒汉模式
class SingletonLazy {
private static SingletonLazy instance = null;
private static Object locker = new Object();
private SingletonLazy() {
};
private static SingletonLazy getInstance() {
//instance是否为null
synchronized (locker) {
if (instance == null) {
instance = new SingletonLazy();
}
}
return instance;
}
}
加锁的同时解决了线程安全,但是会带来阻塞,而且有一些没有必有的阻塞,当我们创建了实例后,还有必要进行阻塞判断吗?当然是没有必要的。
2.解决效率问题
//单列模式中:懒汉模式
class SingletonLazy {
private static SingletonLazy instance = null;
private static Object locker = new Object();
private SingletonLazy() {
};
private static SingletonLazy getInstance() {
//instance是否为null
if (instance == null) {
synchronized (locker) {
if (instance == null) {
instance = new SingletonLazy();
}
}
}
return instance;
}
}
这个时候可能大部分人都认为没有问题了,其实还有一个问题。我们的主题 “指令重排序”
3.解决指令重排序问题
而且这个问题非常可怕哦,不会抛出异常,而是在未来的使用中出问题,所有这一项一定要考虑到,那这个时候其实我们也可以加volatile关键字。
private static volatile SingletonLazy instance = null;
此时编译器围绕这个变量的优化就会非常克制
所以volatile关键字可以解决:
- 可见性问题
- 指令重排序问题(针对赋值)