线程安全问题及其解决

文章目录

  • 一. 线程安全问题
    • 1.1 线程不安全的例子
    • 1.2 线程不安全的原因
      • 1.2.1 随即调度, 抢占式执行
      • 1.2.2 修改共享数据
      • 1.2.3 修改操作非原子性
      • 1.2.4 内存可见性
      • 1.2.5 指令重排序
      • 1.2.6 总结
  • 二. 线程安全问题的解决
    • 2.1 synchronized(解决前三个问题)
      • 2.1.1 synchronized 的锁是什么
      • 2.1.2 synchronized 的特性
    • 2.2 volatile关键字(解决第四,五个问题)
      • 2.2.1 volatile 能保证内存可见性
      • 2.2.2 volatile不保证原子性

一. 线程安全问题

当我们使用多个线程访问同一资源(可以是同一变量, 同一个文件, 同一条记录等) 的时候, 若多个过程只有读操作, 那么不会发生线程安全问题. 但是如果多个线程中对资源有读和写的操作, 就容易出现线程安全问题.

1.1 线程不安全的例子

class Counter {
    public int count = 0;
    public void increase() {
        count+=1;
    }
}
public class Demo {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        Thread thread2 = new Thread(()-> {
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        
        thread1.start();
        thread2.start();
        
        thread1.join();
        thread2.join();
        System.out.println(counter.count);
    }
}

我们认为count应该是100000, 但是实际输出的却不是, 这就说明上述代码有线程安全问题.

1.2 线程不安全的原因

1.2.1 随即调度, 抢占式执行

线程的调度不是按顺序的, 而是抢占式的, 这是系统规定, 我们无法修改.

1.2.2 修改共享数据

上面线程不安全的代码中, 涉及多个线程对counter.count变量进行修改, 此时这个counter.count是一个多线程都能访问到的"共享数据".

线程安全问题及其解决_第1张图片

counter.count这个变量就在堆上, 因此可以被多个线程共享访问.

1.2.3 修改操作非原子性

什么是原子性?

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

那我们应该如何解决这个问题呢? 是不是只要给房间加一把锁, A 进去就把门锁上, 其他人是不是就进不来了. 这样就保证了这段代码的原子性了.

有时也把这个现象叫做同步互斥, 表示操作是互相排斥的.

一条Java语句不一定是原子的, 也不一定只是一条指令.

比如上述代码中的count+=1其实是三个操作组成的:

  • 从内存把数据读到CPU
  • 进行数据更新
  • 把数据写回到CPU
时间 t1 t2
T1 load
T2 load
T3 add
T4 save
T5 add
T6 save

线程安全问题及其解决_第2张图片

不保证原子性会带来的问题: 如果一个线程正在对一个变量操作, 中途其他线程插入进来了, 如果这个操作被打断了, 结果就可能是错误的.

这点也和线程的抢占式调度密切相关. 如果线程不是 “抢占” 的, 就算没有原子性, 也问题不大.

1.2.4 内存可见性

可见性指, 一个线程对共享变量值的修改,能够及时地被其他线程看到.

我们先来看下面的代码

public class Demo {
    private static int isQuit = 0;
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (isQuit == 0) {
                ;
            }
            System.out.println("t1 end");
        });
        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("其输入isQuit的值");
            isQuit = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

我们先输入1, 结果线程t1还未停止.

线程安全问题及其解决_第3张图片

线程安全问题及其解决_第4张图片

我们想让线程t1在isQuit非零时停止, 但事实并非所愿, 这就是内存可见性引发的线程安全问题.

程序在编译运行的时候, Java编译器和JVM可能会对代码作出一些"优化", 在保持原有逻辑不变的情况下, 提高代码的执行效率, 这就称为 编译器优化.

编译器优化本质是靠代码智能地对代码进行分析判断, 进行调整. 这个调整过程大部分情况下都能保持逻辑不变, 但是如果遇到多线程, 就可能会发生差错, 逻辑改变.

while (isQuit == 0)

本质上是两个指令: 一是读内存; 二是比较并跳转

比较操作是在寄存器上进行的, 速度十分快, 相较之下, 读内存操作就会显得很慢.

此时, JVM就会反应到, 这个代码要反复读取同一个内存值, 读出的结果还都是一样的, 于是编译器就直接把读内存这个指令给优化掉了, 只读一次内存, 后续直接拿寄存器中的数据比较, 大大加快了执行速度.

但是, JVM没有预料到我们会在其他线程修改isQuit的值, 编译器没法准确判定出t2线程会不会执行, 什么时候执行, 因此就出现了误判.

虽然其他线程把值修改了, 但是另一个线程中没有重复读取isQuit的值, 这就引发了内存可见性的问题.

1.2.5 指令重排序

指令重排序也是编译器优化的一种手段, 在保证原有逻辑不发生变化的情况下, 对代码执行的顺序进行调整, 使调整后的执行效率变高.

编译器对于指令重排序的前提是 “保持逻辑不发生变化”. 这一点在单线程环境下比较容易判断, 但是在多线程环境下就没那么容易了, 多线程的代码执行复杂程度更高, 编译器很难在编译阶段对代码的执行效果进行预测, 因此激进的重排序很容易导致优化后的逻辑和之前不等价.

1.2.6 总结

线程安全问题的原因:

  • 根本原因: 多个线程之间的调度顺序是"随机的", 操作系统使用"抢占式"执行的策略来调度线程.
    • 当前主流的操作系统都是抢占式执行的.
  • 多个线程同时修改同一个变量.
  • 此处进行的修改不是"原子的".
  • 内存可见性引起的线程安全问题.
  • 指令重排序引起的线程安全问题.

二. 线程安全问题的解决

2.1 synchronized(解决前三个问题)

为了保证每个线程都能正常执行原子操作, Java引入了线程同步机制. 注意: 在任何时候, 最多允许一个线程拥有同步锁, 谁拿到锁就进入代码块, 其他的线程只能在外等着( BLOCKED) .

同步机制的原理, 其实就相当于给某段代码加“锁”, 任何线程想要执行这段代码, 都要先获得“锁”, 我们称它为同步锁.

2.1.1 synchronized 的锁是什么

同步代码块: synchronized 关键字可以用于某个区块前面, 表示只对这个区块的资源实行互斥访问.

synchronized(加锁的对象){
     //需要同步操作的代码
}

public void increase() {
    synchronized(this) {
        count+=1;
    }
}

同步方法: synchronized 关键字直接修饰方法, 表示同一时刻只有一个线程能进入这个方法, 其他线程在外面等着.

public synchronized void method(){
    //可能会产生线程安全问题的代码
}

public synchronized void increase() {
    count+=1;
}

synchronized 进行加锁解锁, 是以对象为维度进行的. 使用synchronized 的时候, 其实是指定了某个具体对象进行加锁.

对于同步代码块来说, 同步锁对象是由程序员手动指定的 ; 但是对于同步方法来说, 同步锁对象只能是默认的:

  • 静态方法: 默认加锁对象是当前类的Class对象(类名.class)

    public synchronized static void method(){
        //可能会产生线程安全问题的代码
    }
    //相当于
    public static void method(){
        synchronized(类名.class) {
            //可能会产生线程安全问题的代码
        }
    }
    
  • 非静态方法: 默认加锁对象是this

如果多个线程对同一个对象进行加锁, 就会出现锁竞争; 如果是多个对象针对不同的对象进行加锁, 不会产生锁竞争.

class Counter {
    public int count = 0;
    public void increase() {
        synchronized(this) {//对this加锁
            count+=1;
        }
    }
}
public class Demo {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        Thread thread2 = new Thread(()-> {
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        // 两个线程对同一个对象(this -> counter)加锁, 那么结果就是100000
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(counter.count);
    }
}
class Counter {
    public int count = 0;
    private Object locker = new Object();
    public void increase() {
        synchronized(this) {//对this加锁
            count+=1;
        }
    }
    public void increase2() {
        synchronized(locker) {//对locker加锁
            count+=1;
        }
    }
}
public class Demo {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        Thread thread2 = new Thread(()-> {
            for (int i = 0; i < 50000; i++) {
                counter.increase2();
            }
        });
        // 两个线程对不同对象(this -> counter / locker)加锁, 那么结果就不是100000.
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(counter.count);
    }
}
class Counter {
    public int count = 0;
    private Object locker = new Object();
    public void increase() {
        synchronized(locker) {//对locker加锁
            count+=1;
        }
    }
    public void increase2() {
        synchronized(locker) {//对locker加锁
            count+=1;
        }
    }
}
public class Demo {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        Thread thread2 = new Thread(()-> {
            for (int i = 0; i < 50000; i++) {
                counter.increase2();
            }
        });
        // 两个线程对同一对象(locker)加锁, 那么结果就是100000.
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(counter.count);
    }
}

总结:

同步锁对象可以是任意类型, 但是必须保证 竞争"同一个共享资源"的多个线程必须针对同一个对象进行加锁。

2.1.2 synchronized 的特性

  1. 互斥

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

    • 进入 synchronized 修饰的代码块, 相当于加锁.
    • 退出 synchronized 修饰的代码块, 相当于解锁.

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

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

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

    理解"阻塞等待"

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

    注意:

    • 上一个线程解锁之后, 下一个线程并不会立即就能获得到锁, 而是要靠操作系统来"唤醒", 这也是操作系统调度的一部分工作.
    • 假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁, 此时 B 和 C 都在阻塞队列中排队等待. 但是当 A 释放锁之后, 虽然 B 比 C 先来的, 但是 B 不一定就能获取到锁, 而是和 C 重新竞争, 并不遵守先来后到的规则.
  2. 刷新内存

    synchronized 的工作过程:

    • 获得互斥锁
    • 从主内存拷贝变量的最新副本到工作的内存
    • 执行代码
    • 将更改后的共享变量的值刷新到主内存
    • 释放互斥锁

2.2 volatile关键字(解决第四,五个问题)

2.2.1 volatile 能保证内存可见性

volatile 修饰的变量, 编译器就不会把读操作优化到都寄存器中, 于是就能保证在循环过程中, 始终能读取内存中的数据, 保证 “内存可见性”.

线程安全问题及其解决_第5张图片

代码在 写入 volatile修饰的变量的时候:

  • 改变线程工作内存中volatile变量副本的值
  • 将改变后的副本的值从工作内存刷新到主内存
    • 工作内存(work memory) : 是指存储区, 包括cpu寄存器和cpu缓存.

代码在 读取 volatile 修饰的变量的时候:

  • 从主内存中读取volatile变量的最新值到线程的工作内存中
  • 从工作内存中读取volatile变量的副本

直接访问工作内存(实际是 CPU 的寄存器或者 CPU 的缓存), 速度非常快, 但是可能出现数据不一致的情况.

加上volatile, 强制读写内存, 速度虽然慢了, 但是数据更准确了.

代码示例

在这个代码中

  • 创建两个线程 t1 和 t2

  • t1 中包含一个循环, 这个循环以 flag == 0 为循环条件.

  • t2 中从键盘读入一个整数, 并把这个整数赋值给 flag.

  • 预期当用户输入非 0 的值的时候, t1 线程结束.

static class Counter {
    public int flag = 0;
}
public static void main(String[] args) {
    Counter counter = new Counter();
    Thread t1 = new Thread(() -> {
        while (counter.flag == 0) {
            // do nothing
       }
        System.out.println("循环结束!");
   });
    Thread t2 = new Thread(() -> {
        Scanner scanner = new Scanner(System.in);
        System.out.println("输入一个整数:");
        counter.flag = scanner.nextInt();
   });
    t1.start();
    t2.start();
}
// 执行效果
// 当用户输入非0值时, t1 线程循环不会结束. (这显然是一个 bug)

t1读的是自己工作内存中的数据

当t2对flag变量进行修改, 此时t1感知不到flag的变化

如果给 flag 加上 volatile

static class Counter {
    public volatile int flag = 0;
}
// 执行效果
// 当用户输入非0值时, t1 线程循环能够立即结束.

2.2.2 volatile不保证原子性

volatile 和 synchronized 有着本质的区别. synchronized 能够保证原子性, volatile 保证的是内存可见性.

代码示例

static class Counter {
    volatile public int count = 0;
    void increase() {
        count++;
   }
}
public static void main(String[] args) throws InterruptedException {
    final Counter counter = new Counter();
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) {
            counter.increase();
       }
   });
    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) {
            counter.increase();
       }
   });
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println(counter.count);
}

此时可以看到, 最终 count 的值仍然无法保证是 100000.

你可能感兴趣的:(Javaee,安全)