在 Java 多线程中如何保证线程的安全性?那我们可以使用 Synchronized 同步锁来给需要多个线程访问的代码块加锁以保证线程安全性。使用 synchronized 虽然可以解决多线程安全问题,但弊端也很明显:加锁后多个线程需要判断锁,较为消耗资源。所以就引出我们今天的主角——volatile 关键字,一种轻量级的解决方案。
首先我们得了解量两个概念:多线程和 JMM。
多线程
进程和线程的概念
创建线程的两种方法
线程的生命周期
Java 内存模型(JMM)
JMM 的概念
JMM 的结构组成部分
volatile 关键字作用
内存可见性
禁止指令重排
1、多线程
(1)进程和线程
进程:一个正在执行中的程序,动态的,是系统进行资源分配和调度的独立单位。
线程:进程中一个独立的控制单元,线程控制着进程的执行。一个进程中至少有一个线程。
(2)创建线程:(Thread 和 Runable)
继承 Thread 类三步走:定义类继承 Thread 类、重写 run 方法、调用线程的 start 方法。
public class ThreadDemo {
public static void main(String[] args) {
// step2:创建该类的对象
Lefthand left = new Lefthand();
Righthand right = new Righthand();
// step3:调用start方法启动线程
left.start();
right.start();
}
}
// step1:继承Thread类,在子类中必须实现run方法
class Lefthand extends Thread {
public void run() {
for (int i = 0; i < 6; i++) {
System.out.println("You are Students!");
try {
sleep(500);
} catch (InterruptedException e) {
}
}
}
}
class Righthand extends Thread {
public void run() {
for (int i = 0; i < 6; i++) {
System.out.println("I am a Teacher!");
try {
sleep(300);
} catch (InterruptedException e) {
}
}
}
}
实现 Runable 接口三步走:定义类实现 Runable 接口、实现 run 方法、通过 Thread 类建立线程对象、start方法。
public class TwoThreadsDemo2 {
public static void main(String[] args) {
SimpleThread2 th1 = new SimpleThread2("Jack");
SimpleThread2 th2 = new SimpleThread2("Tom");
// step3
Thread thread1 = new Thread(th1);
Thread thread2 = new Thread(th2);
thread1.start();
thread2.start();
}
}
// step1
class SimpleThread2 implements Runnable {
String name;
public SimpleThread2(String str) {
name = str;
}
// step2
public void run() {
for (int i = 0; i < 8; i++) {
System.out.println(i + " " + name);
try {
Thread.sleep((long) (Math.random() * 1000));
} catch (InterruptedException e) {
}
}
System.out.println("DONE!" + name);
}
}
两种方式的区别:
实现方式避免了单继承的局限性,线程代码存在接口子类的 run 方法中;继承方式线程代码存放在 Thread 子类的 run 方法中。
(3)线程的生命周期:就绪状态(线程 new 后)、可执行状态(start 方法启动线程,调用 run 方法)、阻塞状态(sleep 方法 和 wait 方法)、死亡状态(stop 方法)
2、Java 内存模型
(1)概念:Java 虚拟机定义的一种抽象规范,使 Java 程序在不同平台上的内存访问效果一致。它决定一个线程对共享变量的写入何时对另一个线程可见。
(2)结构组成:(类比 CPU、高速缓存 、内存 间的关系)
主内存:所有线程共享;共享变量在主内存中存储的是其“本身”;
工作内存:每个线程有自己的工作空间;共享变量在主内存中存储的是其“副本”;
线程对共享变量的所有操作全在工作内存中进行;每个线程只能访问自己的工作内存;变量值的传递只能通过主内存完成。
3、volatile 关键字(用来修饰被不同线程访问和修改的变量)
(1)内存可见性:
某线程对 volatile 变量的修改,对其他线程都是可见的。即获取 volatile 变量的值都是最新的。
Java 中存在一种原则——先行发生原则(happens-before)。其表示两个事件结果之间的关系:如果一个事件发生在另一个事件之间,其结果必须体现。volatile 的内存可见性就体现了该原则:对于一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。
例:
volatile static int a = 0;
//线程 A 在其工作内存中写入变量 a 的新值 1
a = 1 ;
//线程 B 在主内存中读取变量 a 的值输出
System.out.println(a);
需要注意的是 volatile 能保证内存的可见性,但不能保证变量的原子性。
某一线程从主内存获取到共享变量的值,当其修改完变量值重新写入主内存时,并没有去判断主内存的值是否发生改变,有可能会出现意料之外的结果。
例如:当多个线程都对某一 volatile 变量(int a=0)进行 count++ 操作时,由于 count++ 操作并不是原子性操作,当线程 A 执行 count++ 后,A 工作内存其副本的值为 1,但线程执行时间到了,主内存的值仍为 0 ;线程 B又来执行 count++后并将值更新到主内存,主内存此时的值为 1;然后线程 A 继续执行将值更新到主内存为 1,它并不知道线程 B 对变量进行了修改,也就是没有判断主内存的值是否发生改变,故最终结果为 1,但理论上 count++ 两次,值应该为 2。
所以要使用 volatile 的内存可见性特性的话得满足两个条件:
能确保只有单一的线程对共享变量的只进行修改。
变量不需要和其他状态变量共同参与不变的约束条件。
(2)禁止指令重排:
指令重排:JVM 在编译 Java 代码时或 CPU 在执行 JVM 字节码时,对现有指令顺序进行重新排序,优化程序的运行效率。(在不改变程序执行结果的前提下)
指令重排虽说可以优化程序的执行效率,但在多线程问题上会影响结果。那么有什么解决办法呢?答案是内存屏障。内存屏障是一种屏障指令,使 CPU 或编译器对屏障指令之前和之后发出的内存操作执行一个排序的约束。
四种类型:LoadLoad 屏障、StoreStore 屏障、LoadStore 屏障、StoreLoad 屏障。(Load 代表读取指令、Store 代表写入操作)
在 volatile 变量上的体现:(JVM 执行操作)
在每个 volatile 写入操作前插入 StoreStore 屏障;
在写操作后插入 StoreLoad 屏障;
在读操作前插入 LoadLoad 屏障;
在读操作后插入 LoadStore 屏障;
volatile 禁止指令重排在单例模式上有所体现,之前文章有所介绍(链接)。上边介绍的操作只是针对 volatile 读和 volatile 写这种组合情况。还有其他的情况就不一一展开了。
总结:
(1)内存可见性的保证是基于屏障指令的。
(2)禁止指令重排在编译时 JVM 编译器遵循内存屏障的约束,运行时靠屏障指令组织重排。
(3)synchronized 关键字可以保证变量原子性和可见性;volatile 不能保证原子性。
volatile 关键字:
当多个线程进行操作共享数据时,可以保证内存中的数据可见。 相较于 synchronized 是一种较为轻量级的同步策略。
缺点:
1. volatile 不具备“互斥性”
2. volatile 不能保证变量的“原子性”
内存可见性是指当某个线程正在使用对象状态而另一个线程在同时修改该状态,需要确保当一个线程修改了对象状态后,其他线程能够看到发生的状态变化。
可见性错误是指当读操作与写操作在不同的线程中执行时,我们无法确保执行读操作的线程能适时地看到其他线程写入的值,有时甚至是根本不可能的。
我们可以通过同步来保证对象被安全地发布。除此之外我们也可以使用一种更加轻量级的 volatile 变量。
下面用一个案例说明,代码如下:
public class TestVolatile {
public static void main(String[] args) {
ThreadDemo td = new ThreadDemo();
new Thread(td).start();
while (true) {
if (td.isFlag()) {
System.out.println("------------------进来");
}
}
}
}
class ThreadDemo implements Runnable {
private boolean flag = false;
@Override
public void run() {
// 延迟一秒
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
}
flag = true;
System.out.println("flag=" + isFlag());
}
public boolean isFlag() {
return flag;
}
public void setFlag(boolean flag) {
this.flag = flag;
}
}
flag是共享数据存在于主存中,ThreadDemo启动以后主要是来改变flag的值,而main函数本身也是一个线程来读取flag的值。为了让效果更加明显,在ThreadDemo线程里面run方法里面休眠了1秒,目的是先让flag的值先改变,不然main线程里面可能会先读取到flag的值。按理说ThreadDemo线程先改变了flag的值为true,然后while(true)里面读取flag的值应该是true,然后输出,但是运行结果并不是这样。
当一个共享变量被volatile修饰时,外汇返佣它会保证修改的值立即被更新到主存“, 这里的”保证“ 是如何做到的?和 JIT的具体编译后的CPU指令相关吧?
volatile特性
内存可见性:通俗来说就是,线程A对一个volatile变量的修改,对于其它线程来说是可见的,即线程每次获取volatile变量的值都是最新的。
volatile的使用场景
通过关键字sychronize可以防止多个线程进入同一段代码,在某些特定场景中,volatile相当于一个轻量级的sychronize,因为不会引起线程的上下文切换,但是使用volatile必须满足两个条件:
1、对变量的写操作不依赖当前值,如多线程下执行a++,是无法通过volatile保证结果准确性的;
2、该变量没有包含在具有其它变量的不变式中,这句话有点拗口,看代码比较直观。
public class NumberRange {
private volatile int lower = 0;
private volatile int upper = 10;
public int getLower() { return lower; }
public int getUpper() { return upper; }
public void setLower(int value) {
if (value> upper)
throw new IllegalArgumentException(...);
lower = value;
}
public void setUpper(int value) {
if (value < lower)
throw new IllegalArgumentException(...);
upper = value;
}
}
上述代码中,上下界初始化分别为0和10,假设线程A和B在某一时刻同时执行了setLower(8)和setUpper(5),且都通过了不变式的检查,设置了一个无效范围(8, 5),所以在这种场景下,需要通过sychronize保证方法setLower和setUpper在每一时刻只有一个线程能够执行。
上述如果没有了解volatile的作用,那么看下下面的例子可以看出volatile在实际中的作用
下面是我们在项目中经常会用到volatile关键字的两个场景:
1、状态标记量
在高并发的场景中,通过一个boolean类型的变量isopen,控制代码是否走促销逻辑,该如何实现?
public class ServerHandler {
private volatile isopen;
public void run() {
if (isopen) {
//促销逻辑
} else {
//正常逻辑
}
}
public void setIsopen(boolean isopen) {
this.isopen = isopen
}
}
上述一个简单的案例我们可以清楚的看到,现实场景中用户执行了多线程中run()方法,如果需要开启促销逻辑,那么只需要后台设置调用setIsopen(true) 方法,就能很好的控制多线程中方法控制的问题了,该放说明volatile关键字的作用就是告诉该执行方法时时获取最新变量值。