吃透Java线程安全问题

目录

一、什么是线程安全

二、造成线程不安全的原因 

对原子性在多线程并发执行中出现问题的分析

 优化过程中所造成的线程不安全

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);
    }
}

吃透Java线程安全问题_第1张图片

 想这样,在多线程情况下,程序的运行结果不符合我们的预期,这被称为线程不安全


二、造成线程不安全的原因 

根本原因:操作系统的随机调度执行,抢占式执行 

还有:我们可以看到我们的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);
    }
}

 吃透Java线程安全问题_第2张图片

大家有没有想过为什么多个线程同时执行count++的时候就会出现BUG呢?

这是因为我们多个线程同时对同一变量修改的所造成的BUG往往和我们操作的原子性有关!!!这时候的操作往往不是一个整体,多个线程并发执行这些操作就可能出现一些问题

如果我们在变量修改过程中,操作是原子的——只是对应一个机器指令,那么即使是多个线程同时对同一个变量修改也不一定会造成BUG,但也可能造成BUG——要看具体的业务场景

总之我们要避免多个线程同时对同一个变量来操作

吃透Java线程安全问题_第3张图片

对原子性在多线程并发执行中出现问题的分析

注意:

吃透Java线程安全问题_第4张图片

当我们执行t1.start()、t2.start()后,t1线程和t2线程就在操作系统内核中创建出来了t1、t2线程就参与到了系统调度当中

而调度是随机的——他可能先让t1执行几个指令,然后t2再执行几个指令、最后再把CPU的控制权交给t1。

于是因为系统的调度是随机的(这是罪魁祸首,但我们无法改变),当我们多个线程同时执行一些不是整体的操作的时候(++或--)由于并发就会产生一些问题:

 栗子一

吃透Java线程安全问题_第5张图片

栗子二 

吃透Java线程安全问题_第6张图片

为什么会产生上面的BUG呢?

就是因为我们的++操作不是一个整体,是一个由多个指令所组成的操作 

解决方案:也是加锁:“synchronized”,意味着把这三条指令打包成了一组指令,然后把这一组指令看出成一条指令了,类似于数学里的“整体代换”思想。

 吃透Java线程安全问题_第7张图片

 首先我们要明白加锁操作都是针对某一个对象来进行的(加锁本质就是给对象头里设置个标记),加锁有以下几种形式

形式一、 

吃透Java线程安全问题_第8张图片

 形式二、

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
    }
}

形式三、 

吃透Java线程安全问题_第9张图片

 吃透Java线程安全问题_第10张图片

当我们给不同的对象上锁后,如果用住房来比喻

不同的房间相当于是不同的对象,不同的线程相当于是不同的客人

如果房间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();
    }
}

 吃透Java线程安全问题_第11张图片

吃透Java线程安全问题_第12张图片

我们上面就是两个线程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();
    }
}

 吃透Java线程安全问题_第13张图片

对上面补充一下:

吃透Java线程安全问题_第14张图片


 优化过程中所造成的线程不安全

上述三个是造成线程安全问题的的主要原因,除此之外。在编译器/JVM/操作系统对程序优化的过程中也会好心办坏事,造成线程不安全

1、内存可见性引起的安全问题

吃透Java线程安全问题_第15张图片

分析

吃透Java线程安全问题_第16张图片

栗子 

吃透Java线程安全问题_第17张图片

为什么会这种情况呢?

但我们的t1、t2创建好了后,操作系统内存对我们这两个线程进行随机调度执行。那么我们的t1线程在执行过程中就不断地从内存中读取flag的值,然后进行判断。那么就有可能t1线程在执行了好长一会后,t2才执行、输入了一个整数&&改变了flag值。

是操于作系统就发现,怎么回事,这个flag值一直也没有改变,那么t1线程一直不断地从内存中读取flag的值,这样的效率是很低的。于是操作系统就对这种情况进行了优化,t1在第一次从内存中读取flag的值后就不再反复的从内存中读取了,接下来只是不断地进行判断——也就是说:在t2线程把flag的值更改了后,t1线程并没有成功读取到更改后的flag值。

 解决方案

吃透Java线程安全问题_第18张图片

 吃透Java线程安全问题_第19张图片

 

再补充一个栗子

吃透Java线程安全问题_第20张图片 吃透Java线程安全问题_第21张图片


2、指令重排序引起的安全问题

吃透Java线程安全问题_第22张图片

比如说接下来我要干三件事

1、去超市闲逛

2、 回家

3、去超市买菜

如果我安装1->2->3的顺序来执行,是不是很傻、很浪费时间。在程序执行的过程中也是如此,通过顺序的改变和调整就可以达到优化的效果。

但有时候顺序是不能随便改变的

吃透Java线程安全问题_第23张图片

编译器 / JVM / 操作系统 优化优化着,还优化出BUG了,那为啥还要有优化呢?

因为不同的程序员的水平差异是非常大的,通过优化,对我们的程序执行效率可能会有翻倍的提升!!!服务器启动的时候,如果加上编译器优化,启动时间10min!但如果关闭优化,启动时间 > 30 min!!!

所以说,不管怎么优化,我们的大前提是要保证程序的逻辑是不变的!我们是希望在逻辑不变的前提下,通过一些优化的操作来提升效率、提高速度!!!

补充一下在单线程下,保证逻辑不变很容易做到。但在多线程环境下,想有在不改变逻辑的前提下优化就变得很困难了——所以才会出现内存可见性问题、指令重排序问题

三、总结 

吃透Java线程安全问题_第24张图片

对集合类安全性的一点补充:

线性安全的集合类

  • 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: 对象是线程不安全的

你可能感兴趣的:(JavaEE初阶,java,jvm,开发语言,线程安全,并发执行)