Java 多线程系列Ⅱ(线程安全)

简介: Java 多线程系列Ⅱ(线程安全)

一、线程不安全

如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。否则就称之为线程不安全。

线程不安全的原因:

  1. 抢占式执行,可以说“线程的无序调度”是罪魁祸首,万恶之源!!!(是操作系统内核来实现的,程序员无法控制)
  2. 多个线程修改同一个变量。
  3. 修改操作,不是原子(不可分割的最小单位)的。某个操作对应单个cpu指令就是原子的,如果单个操作对应多个CPU指令,大概率不是原子的。正是因为不是原子的,导致多个线程的指令排列存在更多的变数。
  4. 内存可见性,引起的线程不安全。
  5. 指令重排列,引起的线程不安全。

二、线程不安全案例与解决方案

1、修改共享资源

即针对于多个线程修改同一个变量的情况,由于修改操作可能不是原子的(单条cpu指令),在多线程的随机调度下,就会导致多个线程的指令排列存在更多变数。

例如如下代码:

class Counter {
    private int count = 0;
  public void add() {
            count++;
    }

    public int getCount() {
        return count;
    }
}

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

        //等两个线程结束后查看结果
        t1.join();
        t2.join();

        System.out.println(counter.getCount());
    }
}

结果分析:

对于如上代码,两个线程 t1、t2 各自对 count 自增 50000 次,理论情况下结果应为100000,但是实际运行结果小于100000,尽管多次运行依旧如此。以上现象正是因为,在 t1、t2 两个线程修改 count 时,由于每个 ++ 操作都不是原子的,可以分割为(1.读取 2.修改 3.写入),在系统随机调度的加持下就会导致 t1、t2 线程++操作实际指令排列顺序有多种可能,最终导致结果异常。如下图绘制了两种可能出现的情况:

解决方案-加锁

对于以上场景,在保证并发执行的情况下,由于线程的随机调度是系统内核来实现的,程序员不可控,而多个线程修改同一变量又是业务需求,所以要保证该场景下的线程安全我们可以考虑将修改操作变成原子的。而“加锁”可以保证原子性效果。synchronized 是 Java 中用于实现锁的关键字,下面我们详细介绍:

synchronized 使用

Java中使用synchronized针对“对象头”加锁,synchronized 势必要搭配一个具体的对象来使用

(1)synchronized对普通方法加锁

// 给实例方法加锁
public void add() {
    synchronized (this) {
        count++;
    }
}

//如果直接给方法使用synchronized修饰,此时就相当于以this为锁对象
synchronized public void add() {
       count++;
}

(2)synchronized对静态方法加锁

//给静态方法加锁
public static void test2() {
  // Counter.class相当于类对象
  synchronized (Counter.class) {
    
  }
}
//如果直接给方法使用synchronized修饰,此时就相当于以Counter.class为锁对象
synchronized public static void test() {

}

(3)synchronized对任意代码块加锁

// 自定义锁对象
Object locker = new Object();

synchronized (locker) {
    // 代码逻辑
    // . . .
}

拓展:被 synchronized 修饰的方法又叫同步方法;被 synchronized 修饰的代码块又叫同步代码块。

synchronized 特性

  1. 进入 synchronized 修饰的代码块, 相当于 加锁。 退出 synchronized 修饰的代码块, 相当于 解锁。
  2. synchronized修饰的代码块具有原子性效果。即加锁是让多个线程的某个部分进行串行。
  3. synchronized()其中()里的对象,可以是任意一个Object对象,这个对象也被称为锁对象。synchronized用的锁是存在Java对象头里的,可以粗略理解成:每个对象在内存中存储的时候, 都存有一块内存表示当前的 “锁定” 状态,如果当前是 “解锁” 状态, 那么就可以使用, 使用时需要设为 “加锁” 状态,如果当前是 “加锁” 状态, 那么其他线程无法使用, 只能阻塞等待
  4. synchronized是互斥锁,所谓互斥,即同一时间多个线程不能对同一对象加锁。而是同一时刻只能有一个线程获取锁,其他线程阻塞等待。因此多个线程尝试对同一个锁对象加锁,此时就会产生锁竞争,针对不同对象加锁,就不会有锁竞争。
  5. 阻塞等待:针对每一把锁, 操作系统内部都维护了一个等待队列. 当这个锁被某个线程占有的时候, 其他线程尝试进行加锁, 就加不上了, 就会阻塞等待, 一直等到之前的线程解锁之后, 由操作系统唤醒一个新的线程, 再来获取到这个锁。
  6. 获取锁原则:上一个线程解锁之后, 下一个线程并不是立即就能获取到锁. 而是要靠操作系统来 “唤醒”。 这也就是操作系统线程调度的一部分工作.。假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁, 此时 B 和 C 都在阻塞队列中排队等待. 但是当 A 释放锁之后, 虽然 B 比 C 先来的, 但是 B 不一定就能获取到锁, 而是和 C 重新竞争, 并不遵守先来后到的规则。
  7. 拓展:synchronized 既是悲观锁,也是乐观锁。既是轻量级锁,也是重量级锁。轻量级锁部分基于自旋锁实现,重量级锁部分基于挂起等待锁实现。是互斥锁不是读写锁,是非公平锁。(后续介绍)

2、内存可见性

Java内存模型(JMM)

介绍内存可见性之前,我们先简单了解一下java内存模型:

  • 工作内存-work memory :CPU寄存器 + 缓存
  • 主内存-main memory :内存
  1. 线程之间的共享变量存在 主内存 (Main Memory).
  2. 每一个线程都有自己的 “工作内存” (Working Memory) .
  3. 当线程要读取一个共享变量的时候, 会先把变量从主内存拷贝到工作内存, 再从工作内存读取数据.
  4. 当线程要修改一个共享变量的时候, 也会先修改工作内存中的副本, 再同步回主内存.

为什么引入工作内存?

这里引入工作内存主要是因为CPU 访问自身寄存器的速度以及高速缓存的速度, 远远超过访问内存的速度。在某些情况下,这也是提高效率的一种重要手段。比如某个代码中要连续 10000 次读取某个变量的值, 如果 10000 次都从内存读, 速度是很慢的。但是如果只是第一次从内存读, 读到的结果缓存到 CPU 的某个寄存器中, 那么后 9999 次读数据就不必直接访问内存了。效率就大大提高了。

内存可见性问题

内存可见性是指当一个线程修改了某个变量的值,其它线程总是能知道这个变量变化。也就是说,如果线程 A 修改了共享变量 V 的值,那么线程 B 在使用 V 的值时,能立即读到 V 的最新值。

什么是内存可见性引起的多线程安全问题?

一般来说由内存可见性引发的多线程问题,是由于编译器的优化。例如:

public class ThreadExample_unsafe2 {
    public 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(()->{
            Scanner scanner = new Scanner(System.in);
            System.out.print("请输入一个整数:");
            flag = scanner.nextInt();
        });

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

结果分析

如上代码,t1线程中flag == 0涉及到两个CPU指令,假设这两个指令分别是load-从内存读取数据到工作内存(CPU寄存器),cmp-比较寄存器中的值是否为0。对于这两个操作,load的时间开销远远高于cmp。此时编译器在处理的时候发现,load的开销很大,每次load的结果都一样,此时编译器就做了一个非常大胆的决定,即只有第一次load执行从内存读取到工作内存,后续循环的load直接从工作内存读取。所以尽管输入了不为0的整数,因为工作内存数据不变,程序依然继续运行。

关于编译器优化:

针对以上线程安全问题,是编译器优化的结果,关于编译器优化,这是一个很普遍的事,编译器优化就是能够智能调整你代码的执行逻辑,保证程序结果不变的情况下,通过加减语句,通过语句变换等一系列操作,让整个程序的执行效率大大提升。但是对于编译器优化在单线程情况下一般是不会出现任何问题的,但是多线程下不能保证。

解决方案

使用volatile修饰:被关键字volatile修饰的变量,此时编译器就会禁止例如上述优化,能够保证每次都是从内存重新读取数据到工作内存,保证了内存可见性。

3、指令重排列

指令重排,也是程序优化的一种手段,和编译器的优化有直接的关系,也和线程不安全直接相关。如果是单线程的情况下,这样的调整没问题,但是在多线程的情况下就会发生线程安全问题。

例如下面伪代码:

其中线程t1中s = new Student();大体可以分为3步:

  1. 申请内存空间
  2. 调用构造方法(初始化内存数据)
  3. 把对象的引用赋值给s(内存地址的赋值)

如果是单线程下,上述操作很容易保证,如果是多线程下,指令2,3重排先执行3后执行2,在刚执行完指令3后,t2线程执行s.learn();就会出现bug。

解决方案

  1. 当前场景下可使用volatile修饰,因为volatile具有防止指令重拍的作用,可以解决上述可能出现的问题。
  2. 可以对new操作加锁-synchronized

4、synchronized 和 volatile

  1. synchronized 保证原子性,volatile 不保证原子性。
  2. 一般情况下 volatile 适用于一个线程读一个线程写的情况。
  3. 一般情况下 synchronized 适用于多个线程写的情况。

5、拓展知识:修饰符顺序规范

在Java中,修饰符的顺序可以任意排列,但是为了方便阅读和代码的一致性,一般会按照以下的顺序进行排列:

  1. 可见性修饰符(public, protected, private)
  2. 非可见性修饰符(static, final, abstract)
  3. 类型修饰符(class, interface, enum)
  4. 其他修饰符(synchronized, transient, volatile,native, strictfp)


相关文章
|
8天前
|
安全 Java
java 中 i++ 到底是否线程安全?
本文通过实例探讨了 `i++` 在多线程环境下的线程安全性问题。首先,使用 100 个线程分别执行 10000 次 `i++` 操作,发现最终结果小于预期的 1000000,证明 `i++` 是线程不安全的。接着,介绍了两种解决方法:使用 `synchronized` 关键字加锁和使用 `AtomicInteger` 类。其中,`AtomicInteger` 通过 `CAS` 操作实现了高效的线程安全。最后,通过分析字节码和源码,解释了 `i++` 为何线程不安全以及 `AtomicInteger` 如何保证线程安全。
java 中 i++ 到底是否线程安全?
|
2天前
|
存储 设计模式 分布式计算
Java中的多线程编程:并发与并行的深度解析####
在当今软件开发领域,多线程编程已成为提升应用性能、响应速度及资源利用率的关键手段之一。本文将深入探讨Java平台上的多线程机制,从基础概念到高级应用,全面解析并发与并行编程的核心理念、实现方式及其在实际项目中的应用策略。不同于常规摘要的简洁概述,本文旨在通过详尽的技术剖析,为读者构建一个系统化的多线程知识框架,辅以生动实例,让抽象概念具体化,复杂问题简单化。 ####
|
3天前
|
Java 开发者
在Java多线程编程的世界里,Lock接口正逐渐成为高手们的首选,取代了传统的synchronized关键字
在Java多线程编程的世界里,Lock接口正逐渐成为高手们的首选,取代了传统的synchronized关键字
19 4
|
3天前
|
消息中间件 供应链 Java
掌握Java多线程编程的艺术
【10月更文挑战第29天】 在当今软件开发领域,多线程编程已成为提升应用性能和响应速度的关键手段之一。本文旨在深入探讨Java多线程编程的核心技术、常见问题以及最佳实践,通过实际案例分析,帮助读者理解并掌握如何在Java应用中高效地使用多线程。不同于常规的技术总结,本文将结合作者多年的实践经验,以故事化的方式讲述多线程编程的魅力与挑战,旨在为读者提供一种全新的学习视角。
24 3
|
4天前
|
安全 Java 调度
Java中的多线程编程入门
【10月更文挑战第29天】在Java的世界中,多线程就像是一场精心编排的交响乐。每个线程都是乐团中的一个乐手,他们各自演奏着自己的部分,却又和谐地共同完成整场演出。本文将带你走进Java多线程的世界,让你从零基础到能够编写基本的多线程程序。
17 1
|
29天前
|
存储 消息中间件 资源调度
C++ 多线程之初识多线程
这篇文章介绍了C++多线程的基本概念,包括进程和线程的定义、并发的实现方式,以及如何在C++中创建和管理线程,包括使用`std::thread`库、线程的join和detach方法,并通过示例代码展示了如何创建和使用多线程。
38 1
C++ 多线程之初识多线程
|
13天前
|
Java 开发者
在Java多线程编程中,创建线程的方法有两种:继承Thread类和实现Runnable接口
【10月更文挑战第20天】在Java多线程编程中,创建线程的方法有两种:继承Thread类和实现Runnable接口。本文揭示了这两种方式的微妙差异和潜在陷阱,帮助你更好地理解和选择适合项目需求的线程创建方式。
13 3
|
13天前
|
Java 开发者
在Java多线程编程中,选择合适的线程创建方法至关重要
【10月更文挑战第20天】在Java多线程编程中,选择合适的线程创建方法至关重要。本文通过案例分析,探讨了继承Thread类和实现Runnable接口两种方法的优缺点及适用场景,帮助开发者做出明智的选择。
12 2
|
13天前
|
Java
Java中多线程编程的基本概念和创建线程的两种主要方式:继承Thread类和实现Runnable接口
【10月更文挑战第20天】《JAVA多线程深度解析:线程的创建之路》介绍了Java中多线程编程的基本概念和创建线程的两种主要方式:继承Thread类和实现Runnable接口。文章详细讲解了每种方式的实现方法、优缺点及适用场景,帮助读者更好地理解和掌握多线程编程技术,为复杂任务的高效处理奠定基础。
27 2
|
13天前
|
Java 开发者
Java多线程初学者指南:介绍通过继承Thread类与实现Runnable接口两种方式创建线程的方法及其优缺点
【10月更文挑战第20天】Java多线程初学者指南:介绍通过继承Thread类与实现Runnable接口两种方式创建线程的方法及其优缺点,重点解析为何实现Runnable接口更具灵活性、资源共享及易于管理的优势。
26 1