线程安全性:全方位讲解线程安全性问题

什么是线程安全性?

当多个线程访问某个类,不管运行时环境采用何种调度方式或者这些线程如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类为线程安全的–《并发编程实战》

什么是线程不安全?

多线程并发访问时,得不到正确的结果

案例:无论执行多少次 都无法得出预期正确的结果 正确结果(1000)


public class UnSafeThread {

    private static int num = 0;

    private static CountDownLatch countDownLatch = new CountDownLatch(10);
    /**
     * 每次调用对num进行++操作
     */
    public static void inCreate(){
        num ++;
    }

    public static void main(String [] args) {
        //启动10个线程 并发访问inCreate方法
        for (int i =0; i<10;i++){
            new Thread(()->{
                //10个线程不明显 循环100次
                for (int j =0 ;j<100; j++){
                    inCreate();
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                //每个线程执行完成之后,调用countDownLatch
                countDownLatch.countDown();
            }).start();
        }

        while (true){
            if (countDownLatch.getCount() == 0){
                System.out.println(num);
                break;
            }
        }
    }
}

从字节码角度刨析线程不安全操作

控制台打开 就拿我上面的代码案例来操作 进入到相应目录

指定编码编译字节码:javac -encoding UTF-8 UnSafeThread.java

之后目录会生成class文件

反编译字节码:javap -c UnSafeThread.class

复制我图片中选取的部分
在这里插入图片描述

   0: getstatic     #2                  // Field num:I
   3: iconst_1
   4: iadd
   5: putstatic     #2                  // Field num:I
   8: return

getstatic:指定类的静态域,并将其押入栈顶

iconst_1:将int型1押入栈顶

iadd:将栈顶两个int型相加,加完之后将结果押入栈顶

putstatic:为指定类静态域赋值 再进行返回

原因:有可能两个线程都去读取到静态域的值,在进行+1,导致两个线程执行同样的num++,实际上只加了1。

num++并不是原子性操作,被拆分成好几个步骤,在多线程并发执行的情况下,因为cpu调度,多线程快速的切换,有可能两个线程同一时刻都读取了同一个num值,之后对它进行+1的操作,导致最终结果不符合,线程安全性问题。

原子性操作

什么是原子性操作?

这个问题很常见,一句话概括就是要么全部执行,要么全部不执行

如何把非原子性操作变为原子性

还是针对上面的案例进行修改

有的伙伴想到了 我将num加上volatile关键字不就行了么。很遗憾,volatile只是使变量具有可见性,但并不保证原子性,即时使用关键字也不会保证原子性

在方法上加synchronized关键字

在这里插入图片描述

深入理解synchronized

synchronized,使得方法内的非原子性操作变为原子性,就像一把锁,要想进入该代码块的线程必须获得锁,拿了锁之后就把门锁住,直到运行完成退出释放锁,其它线程再进来

基本概念:内置锁、互斥锁

内置锁: 每个java对象都可以用做一个实现同步的锁,这些锁称为内置锁。线程进入同步代码块或方法的时候会自动获得该锁,在退出同步代码块或方法时会释放该锁。获得内置锁的唯一途径就是进入这个锁的保护的同步代码块或方法。

互斥锁: 内置锁是一个互斥锁,这就是意味着最多只有一个线程能够获得该锁,当线程A尝试去获得线程B持有的内置锁时,线程A必须等待或者阻塞,直到线程B释放这个锁,如果B线程不释放这个锁,那么A线程将永远等待下去。

synchronized无法修饰类

  • 修饰普通方法:锁住对象的实例 如果new了两个实例 会各自锁住各自的 另外线程去访问是不会干预的

    案例:结果是同时输出线程名称 锁住的是对象的实例

    public class SynChonizedDemo {
    
        /**
         * 修饰方法
         */
        public synchronized void out() throws InterruptedException {
            System.out.println(Thread.currentThread().getName());
            Thread.sleep(5000L);
        }
        public static void main(String [] args){
            SynChonizedDemo synChonizedDemo = new SynChonizedDemo();
            SynChonizedDemo synChonizedDemo1 =new SynChonizedDemo();
    
            new Thread(()->{
                try {
                    synChonizedDemo.out();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }).start();
    
            new Thread(()->{
                try {
                    synChonizedDemo1.out();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }
    
  • 修饰静态方法:锁住整个类

    案例:会先输出线程0,再休眠输出线程1 锁住整个类 一直到锁释放 下一个线程再进来

    public class SynChonizedDemo {
    
        /**
         * 修饰静态方法
         */
        public static synchronized void staticOut() throws InterruptedException {
            System.out.println(Thread.currentThread().getName());
            Thread.sleep(5000L);
        }
    
        public static void main(String [] args){
            SynChonizedDemo synChonizedDemo = new SynChonizedDemo();
            SynChonizedDemo synChonizedDemo1 =new SynChonizedDemo();
    
            new Thread(()->{
                try {
                    synChonizedDemo.staticOut();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }).start();
    
            new Thread(()->{
                try {
                    synChonizedDemo1.staticOut();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }
    
  • 修饰代码块: 锁住一个对象 synchronized (lock) 即synchronized后面括号里的内容

案例:同一实例下! 锁住对象

public class SynChonizedDemo {

    private Object lock = new Object();
    /**
     * 修饰代码块
     */
    public void myOut(){
        //传入对象会锁住对象
        synchronized (lock){
            System.out.println(Thread.currentThread().getName());
            try {
                Thread.sleep(5000L);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String [] args){
        SynChonizedDemo synChonizedDemo = new SynChonizedDemo();

        new Thread(()->{
                synChonizedDemo.myOut();
        }).start();

        new Thread(()->{
                synChonizedDemo.myOut();
        }).start();
    }
}

Volatile关键字及其使用场景

仅能用于修饰变量

保证该变量的可见性,可通知道其它线程,volatile关键字仅仅保证可见性,并不保证原子性

JVM会发现是共享变量,禁止指令重排序

A、B两个线程同时读取volatile关键字修饰的对象,A读取之后,修改了变量的值,修改后的值,对B线程来说,是可见

使用场景 1:作为线程开关 2:单例,修饰对象实例,禁止指令重排序

单例与线程安全

说最常见的单例模式,毕竟这里不是重点

饿汉式–本身线程安全

典型的空间换时间。在类加载的时候就已经进行实例化,无论之后用不用到。如果该类比较占内存,之后又没用到,就拜拜浪费资源了

案例

/**
 * 饿汉式单例
 */
public class HungerSingleton {

    private static HungerSingleton Instance = new HungerSingleton();

    private HungerSingleton(){}


    public static HungerSingleton getInstance(){
        return Instance;
    }

    public static void main(String [] args){
        for (int i = 0 ;i<10;i++){
            new Thread(()->{
                //每一个线程取到对象实例都是相同的
                System.out.println(HungerSingleton.getInstance());
            }).start();
        }
    }

}

懒汉式–最简单的写法是非线程安全的

典型的时间换空间

案例:输出的都不是相同实例

/**
 * 懒汉式单例Demo
 */
public class LazySingleton {

    //只有在需要才实例
    private static LazySingleton lazySingleton = null;

    private LazySingleton(){

    }

    public static LazySingleton getInstance(){
        //判断实例是否为空
        if(lazySingleton==null){
            try {
                Thread.sleep(1000L);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            lazySingleton = new LazySingleton();
        }
        return lazySingleton;
    }

    public static void main(String [] args){
        for (int i=0;i<10;i++){
            new Thread(()->{
                System.out.println(LazySingleton.getInstance());
            }).start();
        }
    }

}

改进饿汉单例变为线程安全

问题:上面讲到加锁 synchronized 我们直接将锁加到方法上呢? 这样实例就一样了啊 但问题是这样太消性能了
如果我在判断的时候 再锁住类呢? 但这样又衍生出了新的问题 第一次假设10个线程进来 它们都是null 第一个线程运气好 拿到锁了 并且new了实例退出了 但你别忘了 后面还有9个线程等着拿锁呢 所以这样也不行

双重加锁

案例:说是双重加所 不如说双重判断

public class LazySingleton {

    //只有在需要才实例
    private static volatile LazySingleton lazySingleton = null;

    private LazySingleton(){

    }

    public static LazySingleton getInstance(){
        //判断实例是否为空
        if(lazySingleton==null){
            try {
                Thread.sleep(1000L);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (LazySingleton.class){
                if(null == lazySingleton){
                    lazySingleton = new LazySingleton();
                }
            }
        }
        return lazySingleton;
    }

    public static void main(String [] args){
        for (int i=0;i<10;i++){
            new Thread(()->{
                System.out.println(LazySingleton.getInstance());
            }).start();
        }
    }

}

但以上的方式并不是彻彻底底的线程安全

在JVM中有指令重排序 所以volatile关键字的作用就出现了 它能禁止指令重排序 这个没办法演示

如何避免线程安全问题

线程安全性问题成因

多线程环境

多个线程操作同一个共享资源

对该共享资源进行了非原子性操作

如何避免

打破成因中三点任意一点

1:改单线程(必要的代码,加锁访问)

2:不共享资源(ThreadLocal、不共享、操作无状态化、不可变)

3:将非原子性操作改原子性操作(加锁、使用JDK自带的原子性操作的类、JUC提供的相应的并发工具类)

猜你喜欢

转载自blog.csdn.net/q736317048/article/details/113804421
今日推荐