【Java多线程】线程安全问题与解决方案

简介: 【Java多线程】线程安全问题与解决方案

1、线程安全问题

  • 某个代码,无论是单线程下执行还是多线程下执行都不会产生bug,被称之为“线程安全”;
  • 如果在单线程下执行正确,但是多线程下会产生bug,被称之为“线程不安全”或者“存在线程安全问题”;

线程安全问题的典型例子:


public class ThreadDemo {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        // 创建两个线程. 每个线程都针对上述 count 变量循环自增 50w 次
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 500000; i++) {
                count++;
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 500000; i++) {
                count++;
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count = " + count);
    }
}


       按照正常逻辑来看这段代码,结果应该是100w,但是通过多次运行发现这里给的却是一个50w到100w的随机值, 这就是因为出现了线程安全问题导致的结果错误。


问题分析:


count++操作实际上分成三步:


1)load 从内存中读取数据到cpu的寄存器


2)add 把寄存器中的值+1


3)save 把寄存器的值写回内存中


       而由于线程调度是随机调度,抢占式执行的,这就导致了两个线程的count++操作三步骤是会被打乱顺序的。


       例如 t1 线程先执行到 1)的同时, t2 线程也刚好随机调度开始执行 1),导致t1 和 t2 读取到的数据都是0,此时 t1 线程对 寄存器中值0+1,并将 1 写回内存中,接着 t2 也执行相同操作,再次将 1 写回内存中。此时就出现了线程安全问题,两次count++却只让count自增了1次。这就是这段代码为什么不是100w的原因细节。


1.2、线程安全原因

透过两个线程分别对count++,可以看到线程的不安全,有以下原因:

1、根本原因,操作系统上线程的调度策略是“随机调度,抢占式执行”的,这就给线程之间执行的顺序带来了很多的变数。是线程安全问题的“罪魁祸首”。

2、代码结构问题,代码中多个线程同时修改一个变量。

3、直接原因,上述多线程修改操作,本身不是“原子的”,即实际count++操作又被分成了三步操作。如果操作本身是“原子的”,那么它要么执行,要么不执行,就不会出现执行一半,就被调度走,让其他线程“可乘之机”。


  • 针对原因1,系统底层对调度线程的逻辑就是随机调度,抢占式执行,无法干预做出任何调整。
  • 针对原因2,代码结构问题有时候是需求决定的,并不是每次都可以从这里入手,因此对于原因2也不好调整。
  • 针对原因3,既然操作非“原子的”,那么可以通过一些特殊手段将其打包成为“整体”,这也是我们接下来要讲到的加锁。

2、线程加锁

2.1、synchronized 关键字


其中 locker 可以是任意对象,进入 synchronized 修饰的代码块, 相当于加锁,退出 synchronized 修饰的代码块, 相当解锁。


如果一个线程,针对一个对象加上锁之后,其他线程也尝试对这个对象加锁,就会导致锁竞争进而引起阻塞(BLOCKED),这个阻塞会一直持续到上一个线程释放锁为止。

如果是两个线程分别针对不同的对象进行加锁,此时不会由锁竞争,也就不会阻塞。


可以形象的理解成,每个对象在内存中存储的时候,都存有一块内存表示当前的 "锁定" 状态(类似于厕所的 "有人/无人")。


如果当前是 "无人" 状态,那么就可以使用,使用时需要设为 "有人" 状态。


如果当前是 "有人" 状态,那么其他人无法使用,只能排队。


2.2、完善代码

public class ThreadDemo {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        // 随便创建个对象都行
        Object locker = new Object();
        // 创建两个线程. 每个线程都针对上述 count 变量循环自增 50w 次
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 500000; i++) {
                synchronized (locker) {   //对count++进行加锁操作,打包三步为一步
                    count++;
                }
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 500000; i++) {
                synchronized (locker) {   //对count++进行加锁操作,打包三步为一步
                    count++;
                }
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count = " + count);
    }
}


通过对count++的整体加锁,使得每一次的count++都是一个整体,解决了此处的线程安全问题。


2.3、对同一个线程的加锁操作

public static void main(String[] args) {
    Object locker = new Object();
    Thread t = new Thread(() -> {
        synchronized (locker) {
            synchronized (locker) {
                System.out.println("hello synchronized");
            }
        }
    });
    t.start();
}

需要注意的是,这里最直观的感觉是进行了两次加锁,会发生锁冲突。第一次针对locker加锁之后,在还没释放锁的时候又尝试对locker加锁,理论会出现锁冲突,但是这里却可以正常打印。


最关键的问题在于,【Java中的锁是可重入锁】。这两次加锁,其实是在同一个线程中进行的,如果是同一个线程对同一个锁的多次加锁,是不会冲突的。


3、内容补充

当然,还有其他能够导致出现线程安全问题的原因:内存可见性问题以及指令重排序问题


3.1、内存可见性问题

下列代码原本用意是:当用户输入非0数字时,结束线程t1。


package thread;
import java.util.Scanner;
public class ThreadDemo {
    private static int flag = 0;   
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (flag == 0) {
                // 循环体里, 啥都不写会触发内存可见性问题
            }
            System.out.println("t1 线程结束!");
        });
        Thread t2 = new Thread(() -> {
            System.out.println("请输入 flag 的值: ");
            Scanner scanner = new Scanner(System.in);
            flag = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}


实际运行结果时发现无法结束,t2修改了内存,但是t1内有看到这个内存的变化,就称之为“内存可见性”问题。出现这一问题是JVM的代码优化导致的。


t1 线程中的while语句每次循环是都有两个操作:


1、load 读取内存中 flag 的值到 cpu 寄存器中


2、拿到寄存器的值和 0 比较


上述循环中循环的执行速度非常之快,反复的执行1和2,即使是1秒也可能反复执行了几百万次。而在执行的过程中,有两个关键要点:


1、JVM识别到 load 操作执行的几百万次结果每次都一样(输入前的等待时间里)。


2、而由于 load 操作花费的开销远远超过剩余的其他操作(访问寄存器的操作速度远远超过访问内存)


每次循环可能就是百分之九十九的时间都消耗在 load 操作上,而百分之一的时间消耗在其他操作上,而且JVM发现每次 load 操作读取到的数据都是一样的,那么此时JVM就会认为此处每次 load 的操作是否有存在的必要呢,于是乎JVM就可能会自动执行了代码优化,将上述的 load 操作优化了(只有前几次进行了 load,后续发现 load 一直没有变化,分析代码也没发现哪里修改了flag,因此激进的将load操作优化成了直接使用寄存器中之前“缓存”的值),从而达到大幅度提高循环的执行速度的目的。


3.2、指令重排序问题

指令重排序也是编译器优化的一种方式。保证逻辑不变的前提下,调整原有代码的执行顺序,提高程序的效率。


3.3、解决方法

由于上述两种问题都是由于JVM代码优化导致的。


Java提供的 volatile 关键字就可以使上述的优化被强制关闭,可以确保每次循环条件都会重新从内存中读取数据。

强制读取内存,虽然开销大了,效率也低了,但是数据的准确性、逻辑的准确性都提高了。



只需要对 flag 添加一个 volatile 关键字即可解决这一问题。



3.4、总结 volatile 关键字

1、保证内存可见性,每次访问变量必须都要重新读取内存,而不会优化到寄存器/缓存中

2、禁止指令重排序,针对被 volatile 修饰的变量的读写操作相关指令,是不能被重排序的。


目录
相关文章
|
1天前
|
NoSQL Redis 缓存
【后端面经】【缓存】36|Redis 单线程:为什么 Redis 用单线程而 Memcached 用多线程?
【5月更文挑战第17天】Redis常被称为单线程,但实际上其在处理命令时采用单线程,但在6.0后IO变为多线程。持久化和数据同步等任务由额外线程处理,因此严格来说Redis是多线程的。面试时需理解Redis的IO模型,如epoll和Reactor模式,以及其内存操作带来的高性能。Redis使用epoll进行高效文件描述符管理,实现高性能的网络IO。在讨论Redis与Memcached的线程模型差异时,应强调Redis的单线程模型如何通过内存操作和高效IO实现高性能。
25 7
【后端面经】【缓存】36|Redis 单线程:为什么 Redis 用单线程而 Memcached 用多线程?
|
1天前
|
Java
深入理解Java并发编程:线程池的应用与优化
【5月更文挑战第18天】本文将深入探讨Java并发编程中的重要概念——线程池。我们将了解线程池的基本概念,应用场景,以及如何优化线程池的性能。通过实例分析,我们将看到线程池如何提高系统性能,减少资源消耗,并提高系统的响应速度。
11 5
|
1天前
|
消息中间件 安全 Java
理解Java中的多线程编程
【5月更文挑战第18天】本文介绍了Java中的多线程编程,包括线程和多线程的基本概念。Java通过继承Thread类或实现Runnable接口来创建线程,此外还支持使用线程池(如ExecutorService和Executors)进行更高效的管理。多线程编程需要注意线程安全、性能优化和线程间通信,以避免数据竞争、死锁等问题,并确保程序高效运行。
|
1天前
|
存储 Java
【Java】实现一个简单的线程池
,如果被消耗完了就说明在规定时间内获取不到任务,直接return结束线程。
10 0
|
1天前
|
安全 Java 容器
深入理解Java并发编程:线程安全与性能优化
【5月更文挑战第18天】随着多核处理器的普及,并发编程变得越来越重要。Java提供了丰富的并发编程工具,如synchronized关键字、显式锁Lock、原子类、并发容器等。本文将深入探讨Java并发编程的核心概念,包括线程安全、死锁、资源竞争等,并分享一些性能优化的技巧。
|
2天前
|
安全 Java 开发者
Java中的多线程编程:理解与实践
【5月更文挑战第18天】在现代软件开发中,多线程编程是提高程序性能和响应速度的重要手段。Java作为一种广泛使用的编程语言,其内置的多线程支持使得开发者能够轻松地实现并行处理。本文将深入探讨Java多线程的基本概念、实现方式以及常见的并发问题,并通过实例代码演示如何高效地使用多线程技术。通过阅读本文,读者将对Java多线程编程有一个全面的认识,并能够在实际开发中灵活运用。
|
5天前
|
安全 Java
java保证线程安全关于锁处理的理解
了解Java中确保线程安全的锁机制:1)全局synchronized方法实现单例模式;2)对Vector/Collections.SynchronizedList/CopyOnWriteArrayList的部分操作加锁;3)ConcurrentHashMap的锁分段技术;4)使用读写锁;5)无锁或低冲突策略,如Disruptor队列。
20 2
|
5天前
|
安全 Java
【JAVA进阶篇教学】第十篇:Java中线程安全、锁讲解
【JAVA进阶篇教学】第十篇:Java中线程安全、锁讲解
|
5天前
|
安全 Java 调度
深入理解Java中的线程安全与锁机制
【4月更文挑战第6天】 在并发编程领域,Java语言提供了强大的线程支持和同步机制来确保多线程环境下的数据一致性和线程安全性。本文将深入探讨Java中线程安全的概念、常见的线程安全问题以及如何使用不同的锁机制来解决这些问题。我们将从基本的synchronized关键字开始,到显式锁(如ReentrantLock),再到读写锁(ReadWriteLock)的讨论,并结合实例代码来展示它们在实际开发中的应用。通过本文,读者不仅能够理解线程安全的重要性,还能掌握如何有效地在Java中应用各种锁机制以保障程序的稳定运行。
|
5天前
|
安全 Java 程序员
java线程面试题及答案线程安全线程锁线程
java线程面试题及答案线程安全线程锁线程
65 0