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

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6

线程安全

一、线程不安全

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

线程不安全的原因

  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 势必要搭配一个具体的对象来使用

1synchronized对普通方法加锁

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

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

2synchronized对静态方法加锁

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

}

3synchronized对任意代码块加锁

// 自定义锁对象
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
阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6
标签: Java