线程安全问题

一、线程安全 VS 线程不安全?

线程安全指的是代码若是串行执行和并发执行的结果完全一致,就称为该代码是线程安全的。

若多个线程串行执行(单线程执行)的结果和并发执行的结果不同,就称为线程不安全

 顺序执行:

public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });

        Thread t2 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        t1.start();
        t1.join();
        t2.start();
        t2.join();
        System.out.println("两个搬砖人都已经执行结束");
        System.out.println(counter.count);
    }

 

并发执行: 

public class ThreadUnsafeDemo {
    private static class Counter{
        int count = 0;
        void  increase(){
            count++;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });

        Thread t2 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("两个搬砖人都已经执行结束");
        System.out.println(counter.count);
    }
}

 顺序执行和并发执行的结果并不一致,而且同一段代码(并发),每次执行的结果也都不相同。

二、JMM--Java内存模型

JMM(Java Memory Model):java 内存模型:描述多线程场景下,线程的工作内存(CPU的高速缓存和寄存器)和主内存的关系

每个线程都有自己的工作内存,每次读取共享变量(类中的成员变量、静态变量、常量都属于共享变量,在堆中和方法区中存储的变量),不是线程的局部变量,都是先从主内存中将变量加载到自己的工作内存,之后关于此变量的所有操作都是在自己的工作内存中进行,然后写会主内存。

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

三、保证线程安全需满足的三大特性

(一)原子性

        原子性 :该操作对应CPU的一条指令,这个操作不会被中断,要么全部执行,要么全都不被执行,不会存在中间状态。这个操作就是一个原子性操作。

         int a = 10  => 直接将常量10赋值给a变量,要么没赋值,要么赋值成功,原子性

         a += 10  => a = a+ 10  先要读取当前变量a 的值,再将 a + 10计算,最后将计算得出的值重新赋值给a变量(对应3个原子性操作,这条指令是不原子性的

(二)可见性

可见性 :一个线程对共享变量的修改,能够及时的被其他线程看到,这种特性称为可见性(synchronized-上锁 、 volatile关键字 、 final关键字)

(三)防止指令重排 

        指令重排: 代码的书写顺序不一定就是最终JVM或者CPU的执行顺序。(编译器和CPU会对指令优化 -——>前提 : 保证代码的逻辑正确)

                在单线程场景下指令重排没什么问题,但是在多线程场景下就有可能因为指令重排导致错误(一般就是对象还未初始化完成就被别的线程给用了)

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

要确保一段代码的线程安全,需要同时满足可见性、原子性和防止指令重排。

是否会导致线程不安全,一定要注意,多个线程是否在操作同一个共享变量。

(四)解释上述并发代码为什么会发生线程安全问题 

1、increase()方法中的count  ++ 操作不是一个原子性的操作。

首先,线程会从主内存中读取count的值到自己的工作内存中;

然后,计算 count++;

最后,将 count的值写会主内存。

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

2、此时是多个线程同时操作同一个共享变量(count)

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

3、线程对共享变量的修改不能及时被其他线程看到(主内存 和 工作内存)。

4、其中一种可能性(以结果为66211为例)

(1) t1 和 t2 在线程启动时,会将主内存中的 count 值读取到自己的工作内存中,t1先于t2启动,先从主内存中读取count的值 t1.count = 0,此时t2还没有启动。

(2)之后t1开始执行自己的run方法,假设 t1 执行循环16211次,此时 t1.count =16211,线程 t1 将16211 写回主内存中,这时t2才启动,从主内存中读到 count = 16211。

(3)然后 t1 、t2都各自在执行自己的run方法,(解设期间t1、t2都没有再写会主内存),t1 在执行直到最后,把t1.count = 5000,写回内存。

(4)但是 t2 在拿到初始值16211后,一直在读取自己工作内存中的值,而 t1.count = 5000这个值对于 t2 来说并不可见,在 t2 执行完之后,就会把最终值66211写回主内存,把之前t1写回的5000给覆盖了。

四、解决线程安全问题

(一)synchronized关键字 - 监视器锁monitor lock

解决线程安全问题,就是保证原子性和可见性。Synchronized 关键字就能同时满足原子性和可见性。

 private static class Counter{
        int count = 0;
       synchronized void  increase(){
            count++;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });

        Thread t2 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("两个搬砖人都已经执行结束");
        System.out.println(counter.count);
    }

(1)synchronized 的三大特性

        1. 互斥

synchronized 会起到互斥效果(metex lock), 某个线程执行到某个对象的 synchronized 中时(获取到该对象的锁), 其他线程如果也要获取同一个对象的锁,就会处在阻塞等待状态.

当给increase方法加上synchronized关键字,所有进入该方法的线程都需要获取当前counter对象的 “锁”,获取成功才能进入,获取失败,就会进入阻塞状态。

正因为increase方法上上锁处理,多个线程在执行increase方法时,其实是在排队进入,同一时刻只可能有一个线程进入increase方法,执行对count属性的操作。----保证了线程安全

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

        2. 刷新内存

        线程执行synchronized代码块的流程:

        a. 获取对象锁

        b. 从主内存拷贝变量值到工作内存中

        c. 执行代码

        d. 将更改后的值写会主内存

        e. 释放对象锁

因为从a - e 只有一个线程能执行,其他线程都在阻塞。synchronized保证互斥,同一时刻只有一个线程能够获取到这个对象的锁,这就保证了原子性(synchronized修饰的代码块全部执行结束后才会释放锁) 和可见性(操作完毕将共享变量的值写回主内存后才释放锁,其它线程获取锁的时候,这内存中的值一定是更改后的)。

        3. 可重入

        可重入:获取到对象锁的线程可以再次加锁,这种操作就称为可重入。(Java中线程安全锁都是可重入的(包括java.concurrent.lock))

        在Java内部,每个Java对象都有一块内存(对象头),描述当前对象的“锁”信息。信息包含当前对象被哪个线程持有,以及一个计数器-记录当前对象被上锁的次数。

I. 若线程1 需要进入当前对象的同步代码块(synchronized),此时当前对象的对象头中没有锁信息,线程1是第一个获取锁的线程,进入同步代码块,对象头修改持有线程为线程1,计数器从 0 -> 1。当线程1 在同步代码块中再次调用当前对象的其他同步方法,计数器的值再次+1,说明此时对象锁被线程1获取了两次。

II. 若线程2需要进入当前对象的同步代码块,此时当前对象的对象头持有线程为线程1,且计数器值不为0,线程2就会进入阻塞状态,一直等到线程1释放锁为止(直到计数器值为0,才叫真正释放锁)。

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

(2)synchronized使用示例 

        1、synchronized修饰类中的成员方法

        synchronized修饰类中的成员方法,则锁的对象就是当前类的对象。当前这个方法是通过哪个对象调用的,synchronized锁的就是哪个对象。

public class Reentrant {
    public static void main(String[] args) {
        Counter counter1 = new Counter();
        Thread t1 = new Thread(() -> {
            counter1.increase();
        });
        t1.start();
        Counter counter2 = new Counter();
        Thread t2 = new Thread(() -> {
          //  counter2.increase();
             counter1.increase();
        });
        t2.start();
    }
    private static class Counter {
        int val;
        // 锁的是当前Counter对象
        synchronized void increase() {
            val ++;
        }
    }
}

情况1:

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

情况2:

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

        2、synchronized修饰静态方法

        synchronized修饰的类中的静态方法,锁的是当前这个类的class对象(全局唯一,相当于把这个类锁了,同一时刻只能有一个线程访问这个方法(无论有几个对象))

public class Reentrant {
    public static void main(String[] args) {
        Counter counter1 = new Counter();
        Counter counter2 = new Counter();
        Counter counter3 = new Counter();
        Thread t1 = new Thread(() -> {
            counter1.increase2();
        },"t1");
        Thread t2 = new Thread(() -> {
            counter2.increase2();
        },"t2");
        Thread t3 = new Thread(() -> {
            counter3.increase2();
        },"t3");
        t1.start();
        t2.start();
        t3.start();
    }
    private static class Counter {

// 当synchronized修饰静态方法,则相当于将Counter这个类的所有对象都给锁了(其实锁的Counter类的class对象,全局唯一)
        synchronized static void increase2() {
            while (true) {
                System.out.println(Thread.currentThread().getName() + "获取到了锁~~");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        }
    }
}

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

        3、synchronized修饰普通代码块


    private static class Counter {

        void increase3() {
 //.....很多代码
            // 同步代码块,进入同步代码块,必须获取到指定的锁
            //  this表示当前对象引用 ~~,锁的就是当前对象
            // 若锁的是class对象,全局唯一
            synchronized (this) {
                while (true) {
                    System.out.println(Thread.currentThread().getName() + "获取到当前对象的锁");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                }
            }
        }
    }

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

(二)volatile关键字 

(1)volatile的两大特性

        1.可见性

        volatile 关键字可以保证共享变量可见性(强制线程读写主内存的变量值)。相较于普通的共享变量,使用volatile可以保证共享变量的可见性。

        a. 当线程读取的是volatile关键字时,线程直接从主内存中读取该值到工作内存中(无论当前工作内存中是否已经有该值)

      b. 当线程写的是volatile关键字时,将当前修改后的变量值(工作内存中的)立即刷新到主内存中,且其他正在读取此变量值的线程会等待(不是阻塞),直到写回主内存操作完成,保证读的一定是刷新后的主内存值。(对于同一个volatile变量,它的写操作一定发生在它的读操作之前,保证读到数据一定是主内存中刷新后的数据)

        线程直接从主内存中读取该值到工作内存中(无论当前工作内存中是否已经有该值)

public class Volatile {
    private static class Counter {
     volatile int flag = 0;
    }
    public static void main(String[] args) {
        Counter counter = new Counter();
        Thread t1 = new Thread(() -> {
            // volatile变量每次都读写主内存
            while (counter.flag == 0) {
                // 一直循环..
            }
            System.out.println(counter.flag + "退出循环");
        });
        t1.start();
        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("请改变flag的值");
            counter.flag = scanner.nextInt();
        });
        t2.start();
    }
}

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

注意:volatile只保证可见性,但无法保证原子性 ,因此,如果代码不是原子性操作,仍然不是线程安全的!!!

        2.内存屏障

使用volatile关键字修饰的变量,相当于一个内存屏障。

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

(三)final修饰的常量 

final修饰的常量一定是可见的,因为常量在定义时就要赋值,且赋值后无法修改!

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

你可能感兴趣的:(java,jvm,开发语言)