目录
一、什么是线程安全
二、造成线程不安全的原因
对原子性在多线程并发执行中出现问题的分析
优化过程中所造成的线程不安全
1、内存可见性引起的安全问题
2、指令重排序引起的安全问题
三、总结
对集合类安全性的一点补充:
线性安全的集合类
线性不安全的集合类
想给出一个线程安全的确切定义是复杂的,但我们可以这样认为:
如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的
栗子
package Thread;
public class demo77 {
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(); // 两个线程在创建好了后,线程所对应的PCB加入到系统链表,参与系统调度
t2.start();
// 让主线程main等t1、t2执行完了再接着往下走
t1.join();
t2.join();
System.out.println(count);
}
}
想这样,在多线程情况下,程序的运行结果不符合我们的预期,这被称为线程不安全
根本原因:操作系统的随机调度执行,抢占式执行
还有:我们可以看到我们的count是一个全局变量,我们的线程t1、线程t2对count变量同时都进行了修改——++操作(为什么说是同时呢,因为我们的t1线程、t2线程在创建完了后就参与到系统调度,由系统随机分配线程的执行,可能是t2线程先执行10个指令然后t1再执行10个指令,相当于是同时)
那么我们就改一下代码让t1、t2分批次对count修改不就行了吗?
package Thread;
public class demo77 {
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(() -> {
try {
t1.join();// 等t1线程执行完了,t2线程再执行
} catch (InterruptedException e) {
e.printStackTrace();
}
for (int i = 0; i < 5000; i++) {
count++;
}
});
t1.start(); // 两个线程在创建好了后,线程所对应的PCB加入到系统链表,参与系统调度
t2.start();
t2.join(); // 等t2线程执行完了,主线程main再接着执行,执行顺序:t1->t2->打印count
System.out.println(count);
}
}
大家有没有想过为什么多个线程同时执行count++的时候就会出现BUG呢?
这是因为我们多个线程同时对同一变量修改的所造成的BUG往往和我们操作的原子性有关!!!这时候的操作往往不是一个整体,多个线程并发执行这些操作就可能出现一些问题
如果我们在变量修改过程中,操作是原子的——只是对应一个机器指令,那么即使是多个线程同时对同一个变量修改也不一定会造成BUG,但也可能造成BUG——要看具体的业务场景
总之我们要避免多个线程同时对同一个变量来操作
注意:
当我们执行t1.start()、t2.start()后,t1线程和t2线程就在操作系统内核中创建出来了,t1、t2线程就参与到了系统调度当中
而调度是随机的——他可能先让t1执行几个指令,然后t2再执行几个指令、最后再把CPU的控制权交给t1。
于是因为系统的调度是随机的(这是罪魁祸首,但我们无法改变),当我们多个线程同时执行一些不是整体的操作的时候(++或--)由于并发就会产生一些问题:
栗子一
栗子二
为什么会产生上面的BUG呢?
就是因为我们的++操作不是一个整体,是一个由多个指令所组成的操作
解决方案:也是加锁:“synchronized”,意味着把这三条指令打包成了一组指令,然后把这一组指令看出成一条指令了,类似于数学里的“整体代换”思想。
首先我们要明白加锁操作都是针对某一个对象来进行的(加锁本质就是给对象头里设置个标记),加锁有以下几种形式
形式一、
形式二、
package Thread;
class Counter {
public static int count;
// public synchronized void increase() {
// ++count; 这两种写法视为是等价的
// }
public void increase() {
synchronized (this) { // 这里this可以是任意对象,this可以有多个Counter counter1 = new Counter(), Counter counter2 = new Counter();
++count;
}
}
}
public class demo777 {
public static void main(String[] args) throws InterruptedException {
Counter counter1 = new Counter();
Counter counter2 = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
counter1.increase();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
counter1.increase();
}
// 多个线程去调用这个increase方法,其实就是针对这个Counter对象counter1来进行加锁
// 如果一个线程t1获取到了该对象counter1的锁,那么另一个线程t2就要等到counter1对应的锁开了后(t1线程执行完该锁里的内容——++操作)t2才能执行++操作
// 此时++操作相当于是成为了一个整体(相当于一个指令,当一个线程再执行这个加锁的整体的指令的时候,另一个线程只能阻塞等待)
});
t1.start();
t2.start();
t1.join();
t2.join(); // 确保线程t1和线程t2都执行完了,main主线程再接着执行——输出count
System.out.println(Counter.count); // 输出10000
}
}
形式三、
当我们给不同的对象上锁后,如果用住房来比喻
不同的房间相当于是不同的对象,不同的线程相当于是不同的客人
如果房间1住了客人A,那么房间1就上了锁,客人B就需要等客人A不再住房间1(开了锁)然后客人B才能住房间1;或者客人B住其他的房间(其他的对象,没上锁的)
package Thread;
// 测试线程竞争,对锁的竞争
public class demo7777 {
public static Object object1 = new Object();
public static Object object2 = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
// 针对object1对象进行加锁,加锁操作是针对某一个对象来进行的
synchronized (object1) {
System.out.println("t1线程start");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("t1线程finish");
}
});
t1.start();
Thread t2 = new Thread(() -> {
synchronized (object1) { // 针对object1对象来进行加锁操作
System.out.println("t2线程start");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("t2线程finish");
}
});
t2.start();
}
}
我们上面就是两个线程t1和t2同时对object1这个对象进行了加锁,然后t1与t2直接就产生了竞争。从上述代码的实现过程中我们也可以看到,等到t1线程执行完了后,t2线程才开始执行。
但如果是两个线程对不同的对象进行加锁,则没有竞争(就像两个客人(两个线程)住不同的房间(不同的对象)当然不会发生竞争。
package Thread;
// 测试线程竞争,对锁的竞争
public class demo7777 {
public static Object object1 = new Object();
public static Object object2 = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (object1) { // 针对object1对象来进行加锁,加锁操作是针对一个对象来进行的
System.out.println("t1线程start");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("t1线程finish");
}
});
t1.start();
Thread t2 = new Thread(() -> {
synchronized (object2) { // 针对object2对象进行加锁
System.out.println("t2线程start");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("t2线程finish");
}
});
t2.start();
}
}
对上面补充一下:
上述三个是造成线程安全问题的的主要原因,除此之外。在编译器/JVM/操作系统对程序优化的过程中也会好心办坏事,造成线程不安全
分析
栗子
为什么会这种情况呢?
但我们的t1、t2创建好了后,操作系统内存对我们这两个线程进行随机调度执行。那么我们的t1线程在执行过程中就不断地从内存中读取flag的值,然后进行判断。那么就有可能t1线程在执行了好长一会后,t2才执行、输入了一个整数&&改变了flag值。
是操于作系统就发现,怎么回事,这个flag值一直也没有改变,那么t1线程一直不断地从内存中读取flag的值,这样的效率是很低的。于是操作系统就对这种情况进行了优化,t1在第一次从内存中读取flag的值后就不再反复的从内存中读取了,接下来只是不断地进行判断——也就是说:在t2线程把flag的值更改了后,t1线程并没有成功读取到更改后的flag值。
解决方案
再补充一个栗子
比如说接下来我要干三件事
1、去超市闲逛
2、 回家
3、去超市买菜
如果我安装1->2->3的顺序来执行,是不是很傻、很浪费时间。在程序执行的过程中也是如此,通过顺序的改变和调整就可以达到优化的效果。
但有时候顺序是不能随便改变的
编译器 / JVM / 操作系统 优化优化着,还优化出BUG了,那为啥还要有优化呢?
因为不同的程序员的水平差异是非常大的,通过优化,对我们的程序执行效率可能会有翻倍的提升!!!服务器启动的时候,如果加上编译器优化,启动时间10min!但如果关闭优化,启动时间 > 30 min!!!
所以说,不管怎么优化,我们的大前提是要保证程序的逻辑是不变的!我们是希望在逻辑不变的前提下,通过一些优化的操作来提升效率、提高速度!!!
补充一下在单线程下,保证逻辑不变很容易做到。但在多线程环境下,想有在不改变逻辑的前提下优化就变得很困难了——所以才会出现内存可见性问题、指令重排序问题
- Vector:只要是关键性的操作,方法前面都加了synchronized关键字,来保证线程的安全性
- Hashtable:使用了synchronized关键字,所以相较于Hashmap是线程安全的。
- ConcurrentHashMap:使用锁分段技术确保线性安全,是一种高效但是线程安全的集合。
- Stack:栈,也是线程安全的,继承于Vector。
- Hashmap
- Arraylist
- LinkedList
- HashSet
- TreeSet
- TreeMap
Hashmap:HashMap在put操作的时候,如果插入的元素超过了容量(由负载因子决定)的范围就会触发扩容操作,就是resize,这个会重新将原数组的内容重新hash到新的扩容数组中,在多线程的环境下,存在同时其他的元素也在进行put操作,如果hash值相同,可能出现同时在同一数组下用链表表示,造成闭环,导致在get时会出现死循环,所以HashMap是线程不安全的。
Arraylist: List 对象在做 add 时,执行 Arrays.copyOf 的时候,返回一个新的数组对象。当有线程 A、B… 同时进入 grow方法,多个线程都会执行 Arrays.copyOf 方法,返回多个不同的 elementData 对象,假如,A先返回,B 后返回,那么 List.elementData ==A. elementData,如果同时B也返回,那么 List.elementData ==B. elementData,所以线程B就把线程A的数据给覆盖了,导致线程A的数据被丢失。
LinkedList:与Arraylist线程安全问题相似,线程安全问题是由多个线程同时写或同时读写同一个资源造成的。
HashSet:底层数据存储结构采用了Hashmap,所以Hashmap会产生的线程安全问题HashSet也会产生。
SimpleDateFormat: 对象是线程不安全的