来学线程啦~~~
目录
🌴一、线程安全概述
🌲1.什么是线程安全问题
首先我们要明确非常重要的一点——在操作系统中线程的调度是抢占式的、随机的,这就造成线程调度执行时,线程的执行顺序是不确定的。有一些代码执行顺序不同不会影响执行结果,而有一些执行顺序发生改变运行结果会受到影响,这就造成程序会出现BUG。对于多线程并发执行使程序出现BUG的代码我们称为线程不安全的代码,这类问题就叫线程安全问题。
🌷2.一个线程不安全的程序
这样子说大家可能还感受不到这个线程安全问题的含义~~
我来举一个例子,上代码:
public class Demo12 {
static class Counter {//一个Counter类
public int count = 0;
public void increase() {
count++;
}
}
public static void main(String[] args) throws InterruptedException {
final Counter counter = new Counter();
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 50000; i ++) {
counter.increase();//循环调用50000次,使count自增50000次
}
});
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 50000; i ++) {
counter.increase();//循环调用50000次,使count自增50000次
}
});
thread1.start();
thread2.start();
thread1.join();//在main线程前执行
thread2.join();//在main线程前执行
System.out.println("结果为" + counter.count);//预计结果应该为100000
}
}
执行结果:
我们创建了两个线程来使count自增100000次,但是结果却小于100000,这是为什么呢?
原因是线程的调度的顺序是随机的,造成了线程自增的指令集交叉,导致运行时出现两次自增但是值只自增一次的情况,因此得到的值会偏小。
什么是线程自增的指令集交叉呢?
我们知道一次自增条件包含以下三条命令:
a.将内存中的变量的值加载到寄存器中,将它记为load
b.在寄存器中执行自增操作,将它记为add
c.将寄存器的值保存到内存中,将它记为save
由于线程的调度是抢占式的、随机的,那么在进行自增的时候就会有以下三种常见情况:
🍕情况一:线程间指令集无交叉,执行结果与预期结果相等
🍔情况二:线程间指令集存在部分交叉,执行结果比预期结果小
🍟情况三:线程间指令集完全交叉,执行结果比预期结果小
由于load、add和save操作是分离的,而不是聚合在一起的,因此使用多线程对counter进行自增的时候就有可能导致结果与预期结果不一致。
🌴二、线程不安全的原因
🍕1.线程的抢占式调度,导致结果的随机性。
🍟2.多个线程对一个元素进行修改。
以上述 对counter.count进行自增操作 为例,线程1和线程2都对这个counter.count进行修改操作。
(注意: a.多个线程对不同的元素进行修改操作——安全
b.多个线程对一个元素进行读取操作——安全
c.一个线程对一个元素进行修改操作——安全)
🍔3.原子性
不可分割的最小单位,称为原子
不保证原子性会带来什么问题?
如果一个线程正在对一个变量进行操作,中途其他线程插入进来了,如果这个操作被打断了,结果可能就出错了。
这一点也和线程的抢占式调度和多个线程对一个变量进行修改操作密切相关,如果线程不是抢占式调度或者只有一个线程对一个变量进行修改操作,就算没有原子性,那么问题也不大~~
还是以上述 对counter.count进行自增操作 为例,一个 counter.count++ 的操作并不只是有一条命令组成的,它是由 load(加载)、add(增加)、save(存储) 这三个命令组成的,如果这三个命令组成一组命令来执行,也就是 counter.count++ 操作是原子的、不可拆分的,那么在多个线程对counter.count进行修改操作时就不会出现线程间指令集交叉的情况了。
🌷4.可见性
可见性指, 一个线程对共享变量值的修改,能够及时地被其他线程看到.
多个线程工作时都是在自己从工作内存中(CPU寄存器)来执行操作的,线程之间的工作内存是不可见的。
🍕a.线程之间的共享变量存在在主内存
🍔b.每一个线程都有自己的工作内存
🍟c.线程读取共享变量时,先把变量从主内存拷贝到工作内存(寄存器),再从工作内存(寄存器)中读取数据
🌭d.线程修改共享变量时,先修改工作内存(寄存器)的数据,再同步到主内存中
✨一个案例来验证以上几点,上代码:
import java.util.Scanner;
public class Demo13 {
public static int flag = 0;//将标志flag初始化为0
public static void main(String[] args) {
Thread thread = new Thread(() -> {
while (flag == 0) {
//如果标志flag 为0 则一直循环
}
System.out.println("线程结束啦~~");//如果线程结束打印这句话
});
thread.start();//启动线程
System.out.println("修改标志flag为1");
Scanner sc = new Scanner(System.in);
flag = sc.nextInt();
}
}
执行结果:我们将flag置为1了之后发现线程仍未结束
🙃原因就是thread读取的flag一直是工作内存(寄存器)里的值:0
这也和编译器的优化有关系~~
🙉为什么要保证可见性?
为了保证每一次读取的变量都是从主内存中获取的~~
🍭5.代码顺序
现在我们有以下任务需要完成:
🌀a.去快递站拿衣服
🌈b.到教室内卷10分钟
🌊c.去快递站拿零食
如果我们按照 a->b->c 的执行顺序那么就要去两趟快递站,但是如果我们按照 a->c->b 的执行顺序那么只需要去一次快递站。 这种将任务指令优化顺序的行为就叫指令重排序。
🌹JVM翻译字节码指令,CPU执行机器码指令,都可能发生重排序来优化执行效率。编译器对于指令重排序的前提是“保持逻辑不变化”,这一点在单线程的环境下比较容易判断,但是在多线程环境下就有可能发生问题。
加油加油,再坚持坚持
🌴三、解决线程不安全
🌲1.线程加锁
为了解决线程抢占时调度而带来的线程不安全问题,我们可以对操作的对象进行加锁,当一个线程拿到该锁之后,会将这个对象“锁”起来,其他线程如果想对这个对象进行操作,那么就必须要等待这个拿到锁的线程执行完这个对象的任务之后,才能对这个对象进行操作。
举个栗子🌰
有五只小狗想要上厕所,刚好名为“线程1”的小狗抢到了厕所的锁,那么它就可以先去上厕所,而其他小狗由于没抢到锁,那么只能在外面等着,直到“线程1”小狗方便完~~
🧃1.1synchronized关键字
实现以上效果我们可以使用synchronized来对我们的对象进行加锁。
1.1.1 使用示栗🌰
🍕a.直接修饰普通方法: 锁的 SynchronizedDemo 对象
class SynchronizedDemo {
public synchronized void method1() {
}
}
🍔b.修饰代码块: 明确指定锁哪个对象
class SynchronizedDemo {
public void method2() {
synchronized (this) {
}
}
}
🍟c.修饰静态方法: 锁的 SynchronizedDemo 类的对象,明确指定锁哪个对象
class SynchronizedDemo {
public synchronized static void method3() {
}
}
🌭d.修饰静态方法: 锁的 SynchronizedDemo 类的对象,明确指定锁哪个对象
class SynchronizedDemo {
public static void method2() {
synchronized (SynchronizedDemo.class) {
}
}
}
🌷 1.1.2 synchronized 特性
🥝a.互斥性
synchronized 会起到一个互斥效果,某个线程执行到某个对象的 synchronized 中时,如果其他线程也执行到这个对象,那么 synchronized 就会阻塞等待。
进入 synchronized 修饰的代码块就相当于加锁
退出 synchronized 修饰的代码块就相等于解锁
上一个线程解锁之后,下一个线程并不是立刻得倒锁的,而是需要操作系统线程先唤醒,这也是操作系统线程调度的一部分工作。
synchronized的底层是使用操作系统的mutex lock实现的。
🍇b.刷新内存
synchronized 的工作过程:
1.获得互斥锁
2.从主内存拷贝变量的最新副本到工作内存中
3.执行代码
4.将更改后的共享变量的值刷新到主内存中
5.释放互斥锁
因此,synchronized 也能实现可读性。
🔥synchhronized 互斥和刷新内存的特性保证了 线程 的原子性和内存可见性。
🍉c.可重入性
可重入就是,同一个线程可对同一个对象重复加锁,不会把自己锁死。
如何“理解把自己锁死”?
//前置知识,lock锁是不可重入锁
//第一次加锁,加锁成功
lock();
//第二次加锁,加锁失败,由于锁被占用,导致阻塞等待
lock();
而 synchronized 就是可重入锁,它会记录第一次加锁的线程是哪个,如果当这个线程第二次加锁,synchronized 并不会真的加锁,而是将记录的加锁次数加1,当释放锁的时候减1,直到加锁次数为0的时候,才算真正释放。
可重入锁的意义就是降低程序员的负担,但是会增加系统的开销(需要维护锁属于哪个线程,并且加减计数,降低了运行效率) 。
示例:
static class Counter {//一个Counter类
public int count = 0;
public synchronized void increase1() {
count++;//对counter对象第一次加锁
}
public synchronized void increase2() {
increase1();//对counter对象第二次加锁
}
}
上述代码可以运行成功,不会造成阻塞等待。
👨⚕️可以解决的问题:
- 使用 synchronized 关键字可以解决在前面对counter.count进行自增,而结果不如预期的问题:
static class Counter {//一个Counter类
public int count = 0;
public synchronized void increase() {//加synchronized关键字
count++;
}
}
🥝2.volatile 关键字
volatile 能保证内存可见性
实现原理:
代码在修改 volatile 修饰的变量的时候:
改变线程工作内存中 volatile 变量副本的值
将改变后的副本的值从工作内存刷新到主内存
代码在读取 volatile 修饰的变量的时候:
从主内存中读取 volatile 变量的最新值到线程的工作内存中
从工作内存中读取 volatile 变量的副本
👨⚕️可以解决的问题:
- 使用 volatile 关键字,可以解决上述描述可见性时引用的案例——无法修改循环体内的flag:
import java.util.Scanner;
public class Demo13 {
public volatile static int flag = 0;//加了 volatile 关键字
public static void main(String[] args) {
Thread thread = new Thread(() -> {
while (flag == 0) {
//如果标志flag 为0 则一直循环
}
System.out.println("线程结束啦~~");//如果线程结束打印这句话
});
thread.start();//启动线程
System.out.println("修改标志flag为1");
Scanner sc = new Scanner(System.in);
flag = sc.nextInt();
}
}
- volatile 不能保证原子性,而synchronized既能保证原子性又能保证可见性。
volatile 不能保证原子性的示例:
static class Counter {//一个Counter类
public volatile int count = 0;//加了volatile关键字
public void increase() {
count++;
}
}
仍无法保证结果为100000。
🌴四、Java 标准库中的线程安全类
🌀1.Java 标准库中很多都是线程不安全的. 这些类可能会涉及到多线程修改共享数据, 又没有任何加锁措施:
- ArrayList
- LinkedList
- HashMap
- TreeMap HashSet
- TreeSet
- StringBuilder
🌊2.但是还有一些是线程安全的. 使用了一些锁机制来控制:
- Vector (不推荐使用)
- HashTable (不推荐使用)
- ConcurrentHashMap
- StringBuffer
🌈3.还有的虽然没有加锁, 但是不涉及 "修改", 仍然是线程安全的:
- String
🎇五、总结保证线程安全的思路
⚽1.使用没有共享资源的模型
⚾2.适用共享资源只读,不写的模型
①不需要写共享资源的模型
②使用不可变对象
🏀3.直面线程安全
①保证原子性
②保证可见性
③保证顺序性
以上就是多线程初阶(二)线程安全的全部内容啦,
后续还有notify()、wait()、lock()等等等等内容与大家见面~~