> 文章列表 > Java多线程安全风险-Java多线程(2)

Java多线程安全风险-Java多线程(2)

Java多线程安全风险-Java多线程(2)

目录

观察多线程下的风险

线程安全的概念

线程不安全的原因

修改共享数据

原子性

可见性

Java中线程安全的类

synchronized关键字(监视器锁-monitor lock)

特性

刷新内存

可重入

synchronized的使用

volatile 关键字

先来看一个多线程bug:

为什么会产生上面这种问题?

 volatile不保证原子性

synchronized也能保证内存的可见性:

 wait和notify

wait()

wait(Long time)

notify() / notifyAll()

wait和sleep的对比


观察多线程下的风险

class TestClass {public int sum;public void add(){sum ++;}
}
public class Main {public static void main(String[] args) throws InterruptedException {TestClass test = new TestClass();Thread thread1 = new Thread(()-> {for (int i = 0; i < 50000; i++) {test.add();}});Thread thread2 = new Thread(()-> {for (int i = 0; i < 50000; i++) {test.add();}});thread1.start();thread2.start();thread1.join();thread2.join();System.out.println("the final result: " + test.sum);}
}

        我们使用两个线程, 分别对TestClass类下的test实例调用50000次add操作, 两个例子的操作都对test实例中的sum字段进行了50000次自增, 按理来说最后执行的结果应该是100000, 但是我们最终执行了3次, 分别得到了不同的结果, 如下:

 发现: 预期结果是10w, 但是缺和实际上的不符, 三次运行的结果是个随机值, 结果都不确定, 实际结果和预期结果不一样, 这就是bug, 这也是多线程引起的bug之一,

三次的执行结果都不样, 这是为什么呢? 

        其本质上是因为线程之间的调度是不确定的,

此处的sum++操作在本质上被大致分成了3个CPU指令:

  1. load 读取操作, 将内存中的数据读取到CPU寄存器当中
  2. add 自增操作, 将sum的值自增+1
  3. save 保存操作, 将寄存器中sum的自增结果保存到内存当中去

但是两个线程调度顺序时随机的. 不确定的, 实际上的sum++操作就有很多种指令排序的可能.

这里简单的举个例子, 如下: 

 这种情况, 两个线程按顺序调度, 那么就不会产生问题, 但是如果两个线程按照不规则顺序调度, 那么就会产生多线程问题:

 经过上面的讨论, 我们对线程安全的概念做出一些总结

线程安全的概念

        如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线 程安全的。
百度百科中对线程安全的解释;
程安全是多线程编程时的计算机程序代码中的一个概念。在拥有共享数据的多条线程并行执行的程序中,线程安全的代码会通过同步机制保证各个线程都可以正常且正确的执行,不会出现数据污染等意外情况。
        

线程不安全的原因

修改共享数据

上面我们讲到的sum++操作, 就是多个线程对同一个变量进行修改的例子,  此时的sum就是一个多线程能访问到的"共享数据"
此时可能就会有其他不相干的线程来修改这个数据, 此时就可能产生错误

原子性

指事务的不可分割性,一个事务的所有操作要么不间断地全部被执行,要么一个也没有执行。
在我们的代码当中, 每一个java语句不一定是原子的, 不一定只是一条指令, 就例如我们刚才所看到的n++操作, 实际上的大概是由三条指令构成:
  1. 读取内存的数据
  2. 修改数据
  3. 将结果数据保存到内存中去

如果不保证关键语句的原子性, 那么在多线程的情况下, 势必在操作一个变量的时候, 会有另外一个线程插入到其中, 来影响最终结果.

可见性

        可见性指, 一个线程对共享变量值的修改,能够及时地被其他线程看到.
Java 内存模型 (JMM): Java虚拟机规范中定义了Java内存模型. 目的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并 发效果.
  • 线程之间的共享变量存储在 主内存 (Main Memory).
  • 每一个线程都有自己的 "工作内存" (Working Memory) .
  • 当线程要读取一个共享变量的时候, 会先把变量从主内存拷贝到工作内存, 再从工作内存读取数据.
  • 当线程要修改一个共享变量的时候, 也会先修改工作内存中的副本, 再同步回主内存.
由于每个线程有自己的工作内存, 这些工作内存中的内容相当于同一个共享变量的 "副本". 此时修改线程 1 的工作内存中的值, 线程2 的工作内存不一定会及时变化
举个例子:
例如现在主内存中有一个数据: int a = 0;
线程1 读取到了a = 0, 线程2 也读取到了a = 0, 但是线程1对a = 0进行了修改, 对a进行了自增操作, 也就是a = 1, 但是这个a = 1是存放在线程1的工作内存当中去, 并没有写入主内存, 这个时候线程2再对a进行相关操作就会出现bug
1) 为啥整这么多内存?
        实际并没有这么多 "内存". 这只是 Java 规范中的一个术语, 是属于 "抽象" 的叫法.
所谓的 "主内存" 才是真正硬件角度的 "内存". 而所谓的 "工作内存", 则是指 CPU 的寄存器和高速缓存.
2) 为啥要这么麻烦的拷来拷去?
        因为 CPU 访问自身寄存器的速度以及高速缓存的速度, 远远超过访问内存的速度(快了 3 - 4 个数量级, 也 就是几千倍, 上万倍).
既然访问寄存器速度这么快, 还要内存干啥??
答案就是一个字:

Java中线程安全的类

Java 标准库中很多都是线程不安全的. 这些类可能会涉及到多线程修改共享数据, 又没有任何加锁措施.
  • ArrayList
  • LinkedList
  • HashMap
  • TreeMap
  • HashSet
  • TreeSet
  • StringBuilder
但是还有一些是线程安全的. 使用了一些锁机制来控制.
  • Vector
  • HashTable
  • ConcurrentHashMap
  • StringBuffer

StringBuffer的核心方法都带有synchronized, 此外, 有些类虽然没有加锁, 但是被设定了无法修改, 仍然是线程安全的,例如 String类

        对于上述问题, 我们能否把这个sum++操作变成原子的呢? 这就要介绍java的锁机制了, 我们将sum++ 的多个指令集合捆绑在一起,让他能够在一次线程调度的时候全部执行, 这样子就解决这种线程随机调度所引起的问题,.

        锁, 可以保证java语句的原子性. 锁有两个核心操作:

  1. 加锁
  2. 解锁

一旦某个进程加了锁之后, 其他线程也想加锁, 就不能直接加上, 必须先阻塞等待, 知道拿到锁的线程释放了锁为止.

        由于其随机调度性, 如果有三个线程, 让线程1解锁之后, 线程2和线程3谁能拿到所是不确定的.

        java中如何进行加锁, 这就要谈到synchronized关键字

synchronized关键字(监视器锁-monitor lock)

        例如, 我们给上面的sum++进行加锁操作:

    public void add(){synchronized (this) {sum++;}}

        此处使用代买块的方式来表示: 进入synchronized修饰的代码块的时候就会触发加锁操作, 除了代码块就会触发解锁操作.

        其中的this为锁所指向的对象. 如果两个线程针对同一个对象加锁, 此时就会出现"锁竞争"(一file:///C:/Users/L/Desktop/JavaEE初阶/java107_0316_多线程.png个线程拿到了锁, 另外一个线程就需要阻塞等待).
        如果两个线程针对不同的对象进行加锁, 此时就不会存在锁竞争.
        这个里面的()里的对象, 可以是任意一个Object对象(除了内置类型), 此处写了this就相当于给test实例为锁对象:

         对于之前的例子, 我们对其进行加锁操作,并运行:

class TestClass {public int sum;public void add(){synchronized (this) {sum++;}}
}
public class Main {public static void main(String[] args) throws InterruptedException {TestClass test = new TestClass();Thread thread1 = new Thread(()-> {for (int i = 0; i < 50000; i++) {test.add();}});Thread thread2 = new Thread(()-> {for (int i = 0; i < 50000; i++) {test.add();}});thread1.start();thread2.start();thread1.join();thread2.join();System.out.println("the final result: " + test.sum);}
}

 此时才是我们想要的结果.

案例

 例如这种情况, thread1已经先拿到了锁,  如果这个时候thread2再尝试进行加锁, 此时就会出现阻塞等待的情况, thread2就会等待thread1完成指令集并解锁. 这个本质上是把这个并发sum++操作变成了串行操作.

此外, 直接给方法加synchronized:

    synchronized public void add(){sum++;}

此时就相当于以this为所对象.  如果synchronized修饰静态方法, 此时就不是给this加锁, 而是给类对象加锁, 例如test.class.

特性:

file:///C:/Users/L/Desktop/JavaEE初阶/java107_0316_多线程.png互斥

synchronized会起到互斥效果, 某个线程执行到某个对象的synchronized中时, 其他线程如果也执行到同一个对象的synchronized就会阻塞等待.
  • 进入 synchronized 修饰的代码块, 相当于 加锁
  • 退出 synchronized 修饰的代码块, 相当于 解锁
        synchronized (this) {sum++;}

        synchronized用的锁是存在于java的对象里头的, 每个对象在存储的时候, 都有一块用来表示当前锁的状态的内存. 如果是无锁状态, 就可以对其进行加锁, 加锁后需要标识已经加了锁, 其他线程要使用, 如果发现已经加锁, 那么就只能阻塞等待.

需要注意的是, 在阻塞等待后的线程, 不一定是先到的线程会先拿到锁, 这个是不确定的, 是由操作系统进行的随机调度.

刷新内存

synchronized的工作过程:
  1. 获取互斥锁
  2. 从内存中拷贝数据的副本到工作内存中去
  3. 执行代码
  4. 将执行结果返回存储到主内存当中去
  5. 释放锁
所以synchronized也能保证其内存的可见性

可重入

synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题;
什么是把自己锁死?
一个线程没有释放锁, 然后又重新再次加锁
// 第一次加锁, 加锁成功
lock();
// 第二次加锁, 锁已经被占用, 阻塞等待. 
lock();

按照之前所说的, 加了锁的线程, 再次加锁就会进入阻塞等待, 直到第一次的锁被释放, 但是释放锁的过程也是由这个线程来执行的, 这就产生了矛盾, 也就是无法进行解锁操作, 这个时候就被称之为"死锁"

案例:
下面的代码中, doadd(), 和add(), 方法都加了synchronized修饰, 此处的synchronized都是针对当前this对象进行加锁的, 在调用add()方法的时候. 就已经加了一次锁, 执行到doadd()的时候又加了一次所, 但是上一个锁还没有释放. 
这个代码是完全没有问题的, 这就体现了synchronized是课重入锁的
class TestClass {public int sum;synchronized public void add(){doadd();}synchronized public void doadd() {sum++;}
}
public class Main {public static void main(String[] args) throws InterruptedException {TestClass test = new TestClass();Thread thread1 = new Thread(()-> {for (int i = 0; i < 50000; i++) {test.add();}});Thread thread2 = new Thread(()-> {for (int i = 0; i < 50000; i++) {test.add();}});thread1.start();thread2.start();thread1.join();thread2.join();System.out.println("the final result: " + test.sum);}
}

在可重入锁的内部, 包含了 "线程持有者" "计数器" 两个信息:
        如果某个线程加锁的时候, 发现锁已经被人占用, 但是恰好占用的正是自己, 那么仍然可以继续获取到锁, 并让计数器自增. 解锁的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到)

synchronized的使用

        synchronized本质上要修改指定对象的"锁标识", 所以在使用的角度来说也必须要搭配一个对象.

1. 直接修饰普通方法 : 锁的test对象
public class Test{public synchronized void methond() {}
}

2.修饰静态方法: 锁的Test类的对象

public class Test{public synchronized static void method() {}
}

3.修饰代码块: 明确指出锁哪个对象

public class Test {public void method() {synchronized (this) {// 代码块}}
}

volatile 关键字

先来看一个多线程bug:

public class Main {public static int flag = 0;public static void main(String[] args) throws InterruptedException {Thread thread1 = new Thread(()-> {while(flag == 0) {}System.out.println("Thread1结束!!");});Thread thread2 = new Thread(()->{Scanner in = new Scanner(System.in);System.out.println("输入一个整数");flag = in.nextInt();});thread1.start();thread2.start();}
}

上面的例子中, 使用全局变量flag作为线程1结束的标志判断, 然后再从线程2中去改变这个标志, 让线程1结束,, 但是我们在输入非0数字后, 线程并没有立马结束:

 我们使用java的jdk.jconsole工具来查看这个线程1是否继续在运行:

 可以发现这个Thread-0, 也就是我们的线程1, 并没有结束, 线程1 感受不到线程2对flag进行的修改, 

这就是内存可见性的问题, 
但是如果给flag + 上volatile:
 public static volatile int flag = 0;
public class Main {public static volatile int flag = 0;public static void main(String[] args) throws InterruptedException {Thread thread1 = new Thread(()-> {while(flag == 0) {}System.out.println("Thread1结束!!");});Thread thread2 = new Thread(()->{Scanner in = new Scanner(System.in);System.out.println("输入一个整数");flag = in.nextInt();});thread1.start();thread2.start();}
}

为什么会产生上面这种问题?

我们来看这个while循环

 load操作从内存读取数据到寄存器, 然后进行compare操作, 此处的cmp操作, load操作的时间开销是远远超过cmp的.

但是此时的编译器就发现, load的开销很大, 同时每次load的结果都是一样, 于是编译器就把这个load操作给又花掉了, 这样子就只执行了第一次load, 后续就只进行cmp操作.

 volatile不保证原子性

        volatile 和 synchronized 有着本质的区别. synchronized 能够保证原子性, volatile 保证的是内存可见性.
class TestClass {volatile public int sum;public void add() {sum++;}
}
public class Main {public static void main(String[] args) throws InterruptedException {TestClass test = new TestClass();Thread thread1 = new Thread(()-> {for (int i = 0; i < 50000; i++) {test.add();}});Thread thread2 = new Thread(()-> {for (int i = 0; i < 50000; i++) {test.add();}});thread1.start();thread2.start();thread1.join();thread2.join();System.out.println("the final result: " + test.sum);}
}

volatile不能保证其原子性 

synchronized也能保证内存的可见性:

import java.util.Scanner;class TestClass {public int sum;public void add() {sum++;}
}
public class Main {public static void main(String[] args) throws InterruptedException {TestClass test = new TestClass();Thread thread1 = new Thread(()-> {while(true) {synchronized (test) {if (test.sum != 0) {break;}}}});Thread thread2 = new Thread(()-> {Scanner in = new Scanner(System.in);test.sum = in.nextInt();});thread1.start();thread2.start();thread1.join();thread2.join();System.out.println("the final result: " + test.sum);}
}

 wait和notify

由于线程之间都是抢占式执行, 各线程之间的执行顺序难以预知, 但是实际开发中我们有时候需要合理的协调多个线程之间的执行先后顺序.
而程序之间的协调调度主要涉及到三种Object类的方法
1.wait() / wait(Long time) 让线程进入等待状态
2. notify() / notifyAll() 唤醒在当前对象上等待的线程

wait()

wait所执行的流程:
  1.  使执行到wait代码的线程进入等待, (把线程放到等待队列中去)
  2. 释放当前的锁
  3. 满足一定条件的时候被唤醒, 重新尝试获取这个锁

注意: wait要搭配synchronized来使用, 脱离synchronized使用wait会直接抛出异常

wait 结束等待的条件

  • 其他线程调用该对象的 notify 方法.
  • wait等待时间超时, wait方法有一个指定参数的方法, 来制定等待时间
  • 其他线程调用该线程的interrupted方法, 导致wait抛出异常
一个案例
import java.util.Scanner;public class Main {public static void main(String[] args) throws InterruptedException {Object object = new Object();System.out.println("wait之前!");object.wait();System.out.println("wait之后 !!");}
}

运行发现抛出异常(无效锁状态异常):

 我们需要配合synchronized使用:

import java.util.Scanner;public class Main {public static void main(String[] args) throws InterruptedException {Object object = new Object();System.out.println("wait之前!");synchronized (object) {object.wait();}System.out.println("wait之后 !!");}
}

结果才是正确的:

 加锁的对象必须和wait的对象是同一个, 同时notify也要放在synchronized中使用.

但是我们也不能让他一直这样等待下去, 我们应该在需要唤醒他的时候来唤醒它.

wait(Long time)

wait还有一个传入参数版本的, 可以指定等待的时候, 如果时间过了就自动结束等待:

import java.util.Scanner;public class Main {public static void main(String[] args) throws InterruptedException {Object locker = new Object();Thread thread1 = new Thread(()->{while (true) {System.out.println("wait start!");synchronized (locker) {try {locker.wait(3000);} catch (InterruptedException e) {throw new RuntimeException(e);}}System.out.println("wait ended !");}});thread1.start();}
}

notify() / notifyAll()

这个时候就要用到notify了
notify()执行的流程:
  • 方法notify()也要在同步方法或同步块中调用,该方法是用来通知那些可能等待该对象的对象锁的其它线程,对其发出通知notify,并使它们重新获取该对象的对象锁。
  • 如果有多个线程等待,则有线程调度器随机挑选出一个呈 wait 状态的线程。(并没有 "先来后到")
  • notify()方法后,当前线程不会马上释放该对象锁,要等到执行notify()方法的线程将程序执行 完,也就是退出同步代码块之后才会释放对象锁。
一个简单的案例:
import java.util.Scanner;public class Main {public static void main(String[] args) throws InterruptedException {Object locker = new Object();Thread thread1 = new Thread(()->{while (true) {System.out.println("wait start!");synchronized (locker) {try {locker.wait();} catch (InterruptedException e) {throw new RuntimeException(e);}}System.out.println("wait ended !");}});thread1.start();Thread.sleep(1000);// 必须要先wait, 才能notify才有效果, 如果还没有wait就notify, 此时wait就唤不醒,但是不会出现异常Thread thread2 = new Thread(()->{synchronized (locker) {System.out.println("this is notify start!!");locker.notify();System.out.println("this is notify ended !");}});thread2.start();}
}

运行结果:

 注意: 如果此时有三个线程thread1, thread2, thread3中都调用了object.wait, 此时如果在main方法中调用一个object.notify(), 会随机唤醒这三个线程中的一个, 另外两个仍然是wait状态, 如果调用了object.notifyAll, 此时就会把三个线程都唤醒. 然后这三个线程就会同时竞争锁,然后随机调度.

wait和sleep的对比

wait带有一个有时间参数版本的, 可以自动唤醒,  这个时候就感觉和sleep差不多.

但是他们最大的区别在于根本的用法, 或者是说设计这个东西是用来干嘛的, 是不一样的. 

  • wait是解决线程之间的控制顺序, 而sleep是单纯的让线程休眠一会!
  • 实现上也是有区别的: wait需要搭配锁来使用, 必须拿到锁之后才能wait, 而sleep不需要