线程安全问题(面试常考)

简介: 我们目前所知当一个变量n==0,n++了1000次并且 n--了1000次,我们的预期结果为0,但是当两个线程分别执行++和--操作时最后的结果是否为0呢?

🍊一. 观察多线程下n++和n--操作

我们目前所知当一个变量n==0,n++了1000次并且 n--了1000次,我们的预期结果为0,但是当两个线程分别执行++和--操作时最后的结果是否为0呢?


看这样一段代码:

public class ThreadSafe {
    private static int n = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                for(int i = 0;i < 1000;i++){
                        n++;
                }
            }
        });
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                for(int i = 0;i < 1000;i++){
                        n--;
                }
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(n);
    }
}


看一下分别运行3次的结果:

微信图片_20221029142519.jpg微信图片_20221029142521.jpg微信图片_20221029142526.jpg


从结果上看都没有达到我们的预期结果,因为两个线程同时操作一个共享变量时,这其中就涉及到线程安全问题


🍉二. 线程安全概念的引入

我们所知单线程下n++和n--同时执行1000次时结果为0,多线程下大部分不为0,所以我们简单定义为在多线程下和单线程下执行相同的操作结果相同时为线程安全


对于多个线程,操作同一个共享数据(堆里边的对象,方法区中的数据,如静态变量):


如果都是读操作,也就是不修改值,这时不存在安全问题

如果至少存在写操作时,就会存在线程安全问题


🫐三. 线程不安全的原因

🌴1. 原子性

一组操作(一行或多行代码)是不可拆分的最小执行单位,就表示这组操作是具有原子性的


多个线程多次的并发并行的对一个共享变量操作时,该操作就不具有原子性


看这样一个例子,如下图:

image.png


这最终导致的结果是一张票被售卖了两次,这样就具有很大的风险性


注意:我们在写的一行Java代码可能不是原子性的,因为它编译成字节码,或者由JVM把字节码翻译为机器码后就不是一行,也就是多条执行操作


典型的n++,n--操作:

image.png

经过一次n++,n--操作后发现结果不为-1,原因是因为一次++或者--操作是分三步执行:


🍁从内存把数据读到CPU

🍁对数据进行更新操作

🍁再把更新后的操作写入内存


🌾2. 可见性

多个线程工作的时候都是在自己的工作内存中(CPU寄存器)来执行操作的,线程之间是不可见的


1. 线程之间的共享变量存在主内存

2. 每一个线程都有自己的工作内存

3. 线程读取共享变量时,先把变量从主存拷贝到工作内存(寄存器),再从工作内存(寄存)读取数据

4. 线程修改共享变量时,先修改工作内存中的变量值,再同步到主内存


举例子说明上述问题:

public class Demo {
    private static int flag = 0;
    public static void main(String[] args) {
        Thread t = new Thread(() -> {
            while(flag == 0){
            }
            System.out.println("t线程执行完毕");
        });
        t.start();
        Scanner sc = new Scanner(System.in);
        flag = sc.nextInt();
        System.out.println("main线程执行完毕");
    }
}

结果:flag的值已经不为0,但是t线程还没执行结束,因为t线程读flag读的是寄存器中的0

微信图片_20221029142927.png

🍬为什么要保证可见性?


把保证每次读取变量的值时都从主存获取最新的值


🌵3. 有序性

🍬了解重排序:


JVM翻译字节码指令,CPU执行机器码指令,都可能发生重排序来优化执行效率


比如有这样三步操作:(1) 去前台取U盘 (2) 去教室写作业 (3) 去前台取快递

JVM会对指令优化,也就是重排序,新的顺序为(1)(3)(2),这样来提高效率


4. 线程不安全的原因总结

1. 线程是抢占式的执行,线程间的调度充满了随机性

2. 多个线程对同一个变量进行修改操作

3. 对变量的操作不是原子性的

4. 内存可见性导致的线程安全

5. 指令重排序也会影响线程安全


🍒四. 解决线程不安全问题

🌿1. synchronized关键字

🍂1.1 语法格式

1. 修饰普通方法,也叫同步实例方法

public synchronized void doSomething(){
        //...
    }
等同于 
    public void doSomething(){
        synchronized (this) {
            //...
        }
    }

 

2. 修饰静态方法,也叫静态同步方法

public static synchronized void doSomething(){
        //...
    }
等同于
    public static void doSomething(){
        synchronized (A.class) {
            //...
        }
      }  

 

 

3. 修饰代码块


需要显示指定对哪个对象加锁(Java中任意对象都可以作为锁对象)

synchronized (对象) {
        //...
    }

 

🍂1.2 sychronized的作用

sychronized是基于对象头加锁的,特别注意:不是对代码加锁,所说的加锁操作就是给这个对象的对象头里设置了一个标志位


一个对象在同一时间只能有一个线程获取到该对象的锁

sychronized保证了原子性,可见性,有序性(这里的有序不是指指令重排序,而是具有相同锁的代码块按照获取锁的顺序执行)


1. 互斥性


synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待


进入 synchronized 修饰的代码块, 相当于 加锁

退出 synchronized 修饰的代码块, 相当于 解锁


看下图理解加锁过程:

image.png

阻塞等待:


针对每一把锁, 操作系统内部都维护了一个等待队列. 当这个锁被某个线程占有的时候,其他线程尝试进行加锁, 就加不上了,就会阻塞等待, 一直等到之前的线程解锁之后, 由操作系统唤醒一个新的线程, 再来获取到这个锁


2. 刷新主存


synchronized的工作过程:


🍃获得互斥锁

🍃从主存拷贝最新的变量到工作内存

🍃对变量执行操作

🍃将修改后的共享变量的值刷新到主存

🍃释放互斥锁


3. 可重入性


synchronized是可重入锁

同一个线程可以多次申请成功一个对象锁


可重入锁内部会记录当前的锁被哪个线程占用,同时也会记录一个加“锁次数”,对于第一次加锁,记录当前申请锁的线程并且次数加一,但是后续该线程继续申请加锁的时候,并不会真正加锁,而是将记录的“加锁次数加1”,后续释放锁的时候,次数减1,直到次数减为0才是真的释放锁


可重入锁的意义就是降低程序员负担(使用成本来提高开发效率),代价就是程序的开销增大(维护锁属于哪个线程,并且加减计数,降低了运行效率)


如下情形:

image.png

🍂1.3 对n++,n--代码进行修改

public class ThreadSafe {
    private static int n = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                for(int i = 0;i < 1000;i++){
                    synchronized (ThreadSafe.class) {
                        n++;
                    }
                }
            }
        });
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                for(int i = 0;i < 1000;i++){
                    synchronized (ThreadSafe.class){
                        n--;
                    }
                }
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(n);
    }
}


结果:结果为我们预期结果,说明是线程安全的

微信图片_20221029143147.jpg


🌴2. volatile关键字

volatile是用来修饰变量的,它的作用是保证可见性,有序性

注意:不能保证原子性,对n++,n--来说,用volatile修饰n也是线程不安全的


· 代码在写入 volatile 修饰的变量的时候,改变线程工作内存中volatile变量副本的值将改变后的副本的值从工作内存刷新到主内存

· 代码在读取 volatile 修饰的变量的时候,从主内存中读取volatile变量的最新值到线程的工作内存中

从工作内存中读取volatile变量的副本


使用场景:


读操作:读操作本身就是原子性,所以使用volatile就是线程安全的

写操作:赋值操作是一个常量值(写到主存),也保证了线程安全


用volatile修饰变量n看是否线程安全:

public class ThreadSafe {
    private static volatile int n = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                for(int i = 0;i < 1000;i++){
                        n++;
                }
            }
        });
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                for(int i = 0;i < 1000;i++){
                        n--;
                }
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(n);
    }
}

结果:也是不是线程安全的

微信图片_20221029143204.jpg


🌳3. Lock(Java api提供的一个锁,后续在锁策略中介绍)


相关文章
|
2月前
|
存储 缓存 安全
【Java面试题汇总】多线程、JUC、锁篇(2023版)
线程和进程的区别、CAS的ABA问题、AQS、哪些地方使用了CAS、怎么保证线程安全、线程同步方式、synchronized的用法及原理、Lock、volatile、线程的六个状态、ThreadLocal、线程通信方式、创建方式、两种创建线程池的方法、线程池设置合适的线程数、线程安全的集合?ConcurrentHashMap、JUC
【Java面试题汇总】多线程、JUC、锁篇(2023版)
|
2月前
|
消息中间件 前端开发 NoSQL
面试官:线程池遇到未处理的异常会崩溃吗?
面试官:线程池遇到未处理的异常会崩溃吗?
74 3
面试官:线程池遇到未处理的异常会崩溃吗?
|
2月前
|
消息中间件 前端开发 NoSQL
面试官:如何实现线程池任务编排?
面试官:如何实现线程池任务编排?
33 1
面试官:如何实现线程池任务编排?
|
3月前
|
Java
【多线程面试题二十五】、说说你对AQS的理解
这篇文章阐述了对Java中的AbstractQueuedSynchronizer(AQS)的理解,AQS是一个用于构建锁和其他同步组件的框架,它通过维护同步状态和FIFO等待队列,以及线程的阻塞与唤醒机制,来实现同步器的高效管理,并且可以通过实现特定的方法来自定义同步组件的行为。
【多线程面试题二十五】、说说你对AQS的理解
|
3月前
|
Java
【多线程面试题十六】、谈谈ReentrantLock的实现原理
这篇文章解释了`ReentrantLock`的实现原理,它基于Java中的`AbstractQueuedSynchronizer`(AQS)构建,通过重写AQS的`tryAcquire`和`tryRelease`方法来实现锁的获取与释放,并详细描述了AQS内部的同步队列和条件队列以及独占模式的工作原理。
【多线程面试题十六】、谈谈ReentrantLock的实现原理
|
3月前
|
消息中间件 缓存 算法
Java多线程面试题总结(上)
进程和线程是操作系统管理程序执行的基本单位,二者有明显区别: 1. **定义与基本单位**:进程是资源分配的基本单位,拥有独立的内存空间;线程是调度和执行的基本单位,共享所属进程的资源。 2. **独立性与资源共享**:进程间相互独立,通信需显式机制;线程共享进程资源,通信更直接快捷。 3. **管理与调度**:进程管理复杂,线程管理更灵活。 4. **并发与并行**:进程并发执行,提高资源利用率;线程不仅并发还能并行执行,提升执行效率。 5. **健壮性**:进程更健壮,一个进程崩溃不影响其他进程;线程崩溃可能导致整个进程崩溃。
47 2
|
3月前
|
存储 安全 容器
【多线程面试题二十一】、 分段锁是怎么实现的?
这篇文章解释了分段锁的概念和实现方式,通过将数据分成多个段并在每段数据上使用独立锁,从而降低锁竞争,提高并发访问效率,举例说明了`ConcurrentHashMap`如何使用分段锁技术来实现高并发和线程安全。
【多线程面试题二十一】、 分段锁是怎么实现的?
|
3月前
|
安全 Java
【多线程面试题十九】、 公平锁与非公平锁是怎么实现的?
这篇文章解释了Java中`ReentrantLock`的公平锁和非公平锁的实现原理,其中公平锁通过检查等待队列严格按顺序获取锁,而非公平锁允许新线程有更高机会立即获取锁,两者都依赖于`AbstractQueuedSynchronizer`(AQS)和`volatile`关键字以及CAS技术来确保线程安全和锁的正确同步。
【多线程面试题十九】、 公平锁与非公平锁是怎么实现的?
|
3月前
|
存储 缓存 安全
Java多线程面试题总结(中)
Java内存模型(JMM)定义了程序中所有变量的访问规则与范围,确保多线程环境下的数据一致性。JMM包含主内存与工作内存的概念,通过8种操作管理两者间的交互,确保原子性、可见性和有序性。`synchronized`和`volatile`关键字提供同步机制,前者确保互斥访问,后者保证变量更新的可见性。多线程操作涉及不同状态,如新建(NEW)、可运行(RUNNABLE)等,并可通过中断、等待和通知等机制协调线程活动。`volatile`虽不确保线程安全,但能确保变量更新对所有线程可见。
19 0
|
3月前
|
Java 程序员 容器
【多线程面试题二十四】、 说说你对JUC的了解
这篇文章介绍了Java并发包java.util.concurrent(简称JUC),它是JSR 166规范的实现,提供了并发编程所需的基础组件,包括原子更新类、锁与条件变量、线程池、阻塞队列、并发容器和同步器等多种工具。