浅析Java中volatile关键字及其作用

2022-09-04 12:00:46 浏览数 (1)

大家好,又见面了,我是你们的朋友全栈君。

在 Java 多线程中如何保证线程的安全性?那我们可以使用 Synchronized 同步锁来给需要多个线程访问的代码块加锁以保证线程安全性。使用 synchronized 虽然可以解决多线程安全问题,但弊端也很明显:加锁后多个线程需要判断锁,较为消耗资源。所以就引出我们今天的主角——volatile 关键字,一种轻量级的解决方案。

首先我们得了解量两个概念:多线程和 JMM。

多线程

  • 进程和线程的概念
  • 创建线程的两种方法
  • 线程的生命周期

Java 内存模型(JMM)

  • JMM 的概念
  • JMM 的结构组成部分

volatile 关键字作用

  • 内存可见性
  • 禁止指令重排

1、多线程

(1)进程和线程

进程:一个正在执行中的程序,动态的,是系统进行资源分配和调度的独立单位。

线程:进程中一个独立的控制单元,线程控制着进程的执行。一个进程中至少有一个线程。

(2)创建线程:(Thread 和 Runable)

继承 Thread 类三步走:定义类继承 Thread 类、重写 run 方法、调用线程的 start 方法。

代码语言:javascript复制
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方法。

代码语言:javascript复制
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 变量的写操作先行发生于后面对这个变量的读操作。

例:

代码语言:javascript复制
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 不能保证原子性。

发布者:全栈程序员栈长,转载请注明出处:https://javaforall.cn/138323.html原文链接:https://javaforall.cn

0 人点赞