面试必问系列 --- 多线程安全问题

Posted 满眼*星辰

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了面试必问系列 --- 多线程安全问题相关的知识,希望对你有一定的参考价值。

线程安全与不安全

安全线程

线程安全定义:如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。

用主线程来执行增加减少操作,这是安全线程

    static class Counter {
        // 定义的私有变量
        private int num = 0;
        // 任务执行次数
        private final int maxSize = 100000;

        //num++;
        public void incrment() {
            for (int i = 0; i < maxSize; i++) {
                num++;
            }
        }

        //num--
        public void decrment() {
            for (int i = 0; i < maxSize; i++) {
                num--;
            }
        }

        public int getNum() {
            return num;
        }
    }

    public static void main(String[] args) {
        Counter counter = new Counter();
        counter.incrment();
        counter.decrment();
        System.out.println("最终的执行结果:" + counter.getNum());
    }

最后输出0

不安全线程

线程不安全定义:多线程执行中,程序的执行结果和预期不相符就叫作线程不安全

用两个线程分别执行增加,减少操作,这是不安全线程

    static class Counter {
        // 定义的私有变量
        private int num = 0;
        // 任务执行次数
        private final int maxSize = 100000;

        //num++;
        public void incrment() {
            for (int i = 0; i < maxSize; i++) {
                num++;
            }
        }

        //num--
        public void decrment() {
            for (int i = 0; i < maxSize; i++) {
                num--;
            }
        }

        public int getNum() {
            return num;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        Thread t1 = new Thread(() -> {
            counter.incrment();
        });
        t1.start();

        Thread t2 = new Thread(() -> {
            counter.decrment();
        });
        t2.start();

        t1.join();
        t2.join();

        System.out.println("最终的执行结果:" + counter.getNum());
    }

每次结果都不同,不为0

线程执行方式

线程执行操作会分为3步:

  1. load 读取操作:jvm中有一块区域叫作主内存,从中读取count的值
  2. calc 运算操作:读取到了count值后,进行运算操作,对应代码的 ++ 和 –
  3. save保存操作,把运算好的count值再保存回主内存中,一次循环操作才结束

线程不安全原因

1. CPU 抢占执行(根本)

2. 原子性(一块执行)

我们把一段代码想象成一个房间,每个线程就是要进入这个房间的人。如果没有任何机制保证,A进入房间之后,还没有出来;B 是不是也可以进入房间,打断 A 在房间里的隐私。这个就是不具备原子性的

3. 编译器优化(代码优化)

编译器优化再单线程下没问题,可以提升程序的执行效率,单在多线程下就会出现混乱,从而导致线程不安全的问题

4. (内存)可见性

所以不难看出,连个线程并行执行时,每次都用的一个存储空间的值,每次保存都会覆盖这个count值,所以结果自然也就千奇百怪,这就是线程不安全的原因。

在这里插入图片描述
线程执行方式:

  1. 从 L1 缓存、L2缓存、主内存顺序查找并拿去数据到自己的线程工作站中,第一次从主内存中拿取
  2. 在工作站中进行运算操作
  3. 分别把运算后的操作放置到 L1 缓存、L2缓存、主内存中
  4. 在拿取数据的时候,就会拿取 L1 缓存中的内容了

这就内存的不可见性,可能会导致线程的不安全

我们来用代码举例理解:

    private static boolean flag = false;

    public static void main(String[] args) {
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                while (!flag) {

                }
                System.out.println("终止执行");
            }
        });
        t1.start();

        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("设置 flag 为 true");
                flag = true;
            }
        });
        t2.start();

    }

执行结果
在这里插入图片描述
可以看到明明将flag改为false了,但是代码并不会终止,一直在运行

这是由于线程的不可见性导致线程不安全出现的情况。

在这里插入图片描述
线程的工作方式:

  1. 先去自己的工作内存中找变量
  2. 去主内存里面找变量

本来两个线程都会从主内存中取到flag变量,但是由于线程 t1 中的while循环体中没有任何代码,cpu这时候进行优化,每次调用的就是t1工作区中的数据,这个数据是没有改变的,t2只改变了主内存中的数据,所以导致了t1并没有接收到flag更改后的数据,这也就是线程安全中的可见性的问题。

5. 多个线程修改了同一个变量

解决方案
(一)如果我们把 t1.join() 移动到 t2.start()之前,这样就相当于 t1 执行完后 t2 再执行,相当于串行操作,就不会有这种线程不安全的情况了

(二)让两个线程修改他们各自的变量

static class Counter {

        // 任务执行次数
        private final int maxSize = 100000;

        //num++;
        public int incrment() {
            int num1 = 0;
            for (int i = 0; i < maxSize; i++) {
                num1++;
            }
            return num1;
        }

        //num--
        public int decrment() {
            int num2 = 0;
            for (int i = 0; i < maxSize; i++) {
                num2--;
            }
            return num2;
        }

    }

    private static int num1 = 0;
    private static int num2 = 0;

    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        Thread t1 = new Thread(() -> {
            num1 = counter.incrment();
        });
        t1.start();

        Thread t2 = new Thread(() -> {
            num2 = counter.decrment();
        });
        t2.start();

        t1.join();
        t2.join();

        System.out.println("最终的执行结果:" + (num1 + num2));
    }

volatile 轻量级解决 “线程安全” 的方案

我们只需要将全局变量flag中加上volatile关键字就可以啦

private static volatile boolean flag = false;

执行结果:
在这里插入图片描述

volatile作用:

  1. 禁止指令重排序
  2. 解决线程可见性问题
    实现原理:当操作完变量之后,强制删除掉工作内存中的变量

注意事项:volatile不能解决原子性问题

线程安全通用解决方案

  1. CPU抢占调度(不能)
  2. 每个线程操作自己的变量(可能行):不通用,修改难度大
  3. 在关键代码上让所有的 CPU 排队执行,加锁。

加锁步骤

锁操作的关键步骤:

  1. 尝试获取(如果成功拿到锁加锁,否则排队等待)
  2. 释放锁

1. synchronized 加锁

【JVM 层面的解决方案,自动帮我们进行加锁和释放锁】

实现原理

操作层面

  • synchronized的底层是使用操作系统的互斥锁mutex lock实现的。

JVM层面

  • 当线程释放锁时,JMM会把该线程对应的工作内存中的共享变量刷新到主内存中
  • 当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码 必须从主内存中读取共享变量

JAVA层面

  • 锁对象 mutex
  • 锁存放的地方:变量的对象头
    synchronized用的锁是存在Java对象头里的
    在这里插入图片描述

synchronized同步快对同一条线程来说是可重入的,不会出现自己把自己锁死的问题;

	synchronized (lock) {
	    synchronized (lock) {
	        number--;
	    }
	}

因为有可重入的性值,当获取到lock这把锁后,无论加几层这把锁,都可以进入

同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入

锁升级过程

synchronized 在JDK 6 之前使用重量级锁实现的,性能非常低,所以用的并不多
JDK6 对synchronized 做了一个优化(锁升级):
在这里插入图片描述

synchronized解决线程问题代码实例

那我们用synchronized来解决之前线程不安全的问题

    //全局变量
    private static int number = 0;
    //循环次数
    private static final int maxSize = 100000;

    public static void main(String[] args) throws InterruptedException {
        Object lock = new Object();
        //+10w
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < maxSize; i++) {
                    synchronized (lock) {
                        number++;
                    }
                }
            }
        });
        t1.start();

        //-10w
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < maxSize; i++) {
                    synchronized (lock) {
                        number--;
                    }
                }
            }
        });
        t2.start();

        //等t1,t2线程执行完
        t1.join();
        t2.join();

        System.out.println("运行结果为:" + number);
    }


可以看到,这时候我们进行加减时,最终结果为0
在这里插入图片描述

执行流程:
在这里插入图片描述

注意事项:在进行加锁操作的时候,同一组业业务一定是同一个锁对象

synchronized 的使用场景

  1. 使用 synchronized 来修饰代码块(加锁对象可以自定义);
	Object lock = new Object();
	
	synchronized (lock) {
	    number++;
	}
  1. 使用 synchronized 来修饰静态方法(加锁对象时当前的类对象)
    public static synchronized void increment() {
        for (int i = 0; i < maxSize; i++) {
            number++;
        }
    }
  1. 使用 synchronized 来修饰普通方法(加锁对象是当前类的实例)
    public synchronized void increment() {
        for (int i = 0; i < maxSize; i++) {
            number++;
        }
    }

而 Lock 只能用来修饰代码块

2. Lock 手动锁

程序员自己加锁和释放锁】

代码实现

java.util.concurrent.locks这个包里面 简称为 JUC

我们把之前报错的代码用 Lock 改进一下

    //全局变量
    private static int number = 0;
    //循环次数
    private static final int maxSize = 100000;

    public static void main(String[] args) throws InterruptedException {
        //1.创建手动锁
        Lock lock = new ReentrantLock();

        //+10w
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < maxSize; i++) {
                    //2.加锁
                    lock.lock();
                    try {
                        number++;
                    }finally {
                        //3.释放锁
                        lock.unlock();
                    }
                }
            }
        });
        t1.start();

        //-10w
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < maxSize; i++) {
                    lock.lock();
                    try {
                        number--;
                    }finally {
                        lock.unlock();
                    }
                }
            }
        });
        t2.start();

        //等t1,t2线程执行完
        t1.join();
        t2.join();

        System.out.println("运行结果为:" + number);
    }

注意事项:一定要把 lock()放在 try 外面

  • 如果j将 lock()方法放在 try 里面,那么当 try 里面的代码出现异常以后,那么就会执行 finally里面的释放锁的代码,单这个时候加锁还没成功,就去释放锁
  • 如果j将 lock()方法放在 try里面,那么当执行finally里面释放锁的代码时候就会报错(线程状态异常),释放锁的异常就会覆盖掉业务代码的异常报错,从而增加了排除错误成本

公平锁与非公平锁

公平锁可以按顺序进行执行,而非公平锁执行的效率更高

在 java 中所有的锁默认的策略都是非公平锁(synchronized锁机制就是非公平锁)

Lock 默认的锁策略也是非公平锁,但是 Lock 可以显式地声明为公平锁

Lock lock = new ReentrantLock(true);

只需要在参数设置为 true 就可以设置为公平锁

用公平锁实现双线程打印“AABBCCDD”

    public static void main(String[] args) throws InterruptedException {
        //声明一个公平锁
        Lock lock = new ReentrantLock(true);

        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                for(char item : "ABCD".toCharArray()) {
                    lock.lock();
                    try {
                        System.out.print(item);
                    }finally {
                        lock.unlock();
                    }
                }
            }
        };

        Thread t1 = new Thread(runnable,"t1");
        Thread t2 = new Thread(runnable,"t2");

        Thread.sleep(10);

        t1.start();
        t2.start();
    }

volatile 和 synchronized 有什么区别?

  1. volatile 可以解决内存可见性问题 和 禁止指令重排序,但 volatile 不能解决原子性问题;
  2. synchronized 是用来保证线程安全的,也就是synchronized 可以解决任何关于线程安全的问题(关键代码排队执行,始终只有一个线程会执行加锁操作;原子性问题 等 )

synchronized 和 Lock 之间的区别?

  1. synchronized 既可以修饰代码块,又可以修饰静态方法或者普通方法;
    而Lock 只能修饰代码块
  2. synchronized 只有非公平锁策略,而 Lock 既可以是公平锁也可以是非公平锁(RentrantLock 默认是非公平锁,也可以通过构造函数设置 true 声明它为公平锁)
  3. ReentrantLock 更加灵活(比如 try Lock)
  4. synchronized 是自动加锁和释放锁的,而 ReentrantLock 需要自己手动加锁和释放锁

以上是关于面试必问系列 --- 多线程安全问题的主要内容,如果未能解决你的问题,请参考以下文章

面试必问之 ConcurrentHashMap 线程安全的具体实现方式

面试必问 | 一个线程从创建到消亡要经历哪些阶段?

面试必问 | 一个线程从创建到消亡要经历哪些阶段?

面试必问:HashMap 底层实现原理

面试必问:HashMap 底层实现原理

Java面试必问之---HashMap