参考文献:
Java并发——线程同步Volatile与Synchronized详解)..
Java中synchronized和volatile的区别
volatile和synchronized的区别
synchronized修饰静态方法和普通方法的区别
volatile底层实现原理
目录
1.Java内存模型(JMM)
提到这两个有关于线程的关键字,那么我们不得不提到Java的内存模型了(JMM),下面我们先看一下Java内存模型在处理多线程方面的工作原理图。
Java内存模型(java Memory Model) 描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取出变量这样的底层细节。
首先介绍两个概念
- 可见性:一个线程对共享变量值的修改,能够及时地被其他线程看到。
- 共享变量:如果一个变量在多个线程的工作内存中都存在副本,那么这个变量就是这几个线程的共享变量。
共享变量可见性实现的原理
线程1对共享变量的修改要想被线程2及时看到,必须要经过如下两个步骤:
- 把工作内存1中更新过的共享变量刷新到主内存中
- 将主内存中最新的共享变量的值更新到工作内存2中
下图为一个共享变量实现可见性原理的一个示例:
其中,线程对共享变量的操作,遵循一下两条规则:
- 线程对共享变量的所有操作都必须在自己的工作内存中进行,不能直接从主内存中读写。
- 不同线程之间无法直接访问其他线程工作内存中的变量,线程间变量值的传递需要通过主内存来完成。
可见性
要实现共享变量的可见性,必须保证两点:
- 线程修改后的共享变量值能够及时从工作内存刷新到主内存中。
- 其他线程能够及时把共享变量的最新值从主内存更新到自己的工作内存中。
可见性的实现方式:
- synchronized
- volatile
2.synchronized
- 原子性(同步)
- 可见性
JMM关于synchronized的两条规定:
- 线程解锁前,必须把共享变量的最新值刷新到主内存中。
- 线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量时,需要从主内存中重新读取最新的值(注意:加锁与解锁需要是同一把锁)。
注意:线程解锁前对共享变量的修改在下次加锁时对其他线程可见。
线程执行互斥代码的过程:
1. 获得互斥锁
2. 清空工作内存
3. 从主内存拷贝变量的最新副本到工作的内存
4. 执行代码
5. 将更改后的共享变量的值刷新到主内存
6. 释放互斥锁
重排序
代码书写的顺序与实际执行的顺序不同,指令重排序是编译器或处理器为了提高程序性能而做的优化。
- 编译器优化的重排序(编译器优化)
- 指令级并行重排序(处理器优化)
- 内存系统的重排序(处理器优化)
synchronized可作用于一段代码或方法,既可以保证可见性,又能够保证原子性。
可见性体现在:通过synchronized或者Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存中。
原子性表现在:要么不执行,要么执行到底。
例1:
必须使用synchronized而不能使用volatile的场景
public class Test {
public volatile int inc = 0;
public void increase() {
inc++;
}
public static void main(String[] args) {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1) //保证前面的线程都执行完
Thread.yield();
System.out.println(test.inc);
}
}
例子中用new了10个线程,分别去调用1000次increase()方法,每次运行结果都不一致,都是一个小于10000的数字。自增操作不是原子操作,volatile 是不能保证原子性的。使用volatile修饰int型变量i,多个线程同时进行i++操作。比如有两个线程A和B对volatile修饰的i进行i++操作,i的初始值是0,A线程执行i++时刚读取了i的值0,就切换到B线程了,B线程(从内存中)读取i的值也为0,然后就切换到A线程继续执行i++操作,完成后i就为1了,接着切换到B线程,因为之前已经读取过了,所以继续执行i++操作,最后的结果i就为1了。同理可以解释为什么每次运行结果都是小于10000的数字。
但是使用synchronized对部分代码进行如下修改,就能保证同一时刻只有一个线程获取锁然后执行同步代码。运行结果必然是10000。
public int inc = 0;
public synchronized void increase() {
inc++;
}
3. volatile
使用场景:
① 一个线程写,多个线程读
② 写操作之间没有任何关联(比如i = 1;i= 2 这个是没有关系的。比如:i= i+ 1,这个就是有关联的)
3.1 volatile修饰的变量具有可见性
volatile是变量修饰符,其修饰的变量具有可见性。
可见性也就是说一旦某个线程修改了该被volatile修饰的变量,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,可以立即获取修改之后的值。
在Java中为了加快程序的运行效率,对一些变量的操作通常是在该线程的寄存器或是CPU缓存上进行的,之后才会同步到主存中,而加了volatile修饰符的变量则是直接读写主存。
例2:volatile的使用举例
class MyThread extends Thread {
private volatile boolean isStop = false;
public void run() {
while (!isStop) {
System.out.println("do something");
}
}
public void setStop() {
isStop = true;
}
}
线程执行run()的时候我们需要在线程中不停的做一些事情,比如while循环,那么这时候该如何停止线程呢?如果线程做的事情不是耗时的,那么只需要使用一个标志即可。如果需要退出时,调用setStop()即可。这里就使用了关键字volatile,这个关键字的目的是如果修改了isStop的值,那么在while循环中可以立即读取到修改后的值。
如果线程做的事情是耗时的是耗时的,那么可以使用interrupt方法终止线程 。如果在子线程“睡觉”时被interrupt,那么子线程可以catch到InterruptExpection异常,处理异常后继续往下执行。
3.2 volatile禁止指令重排
volatile可以禁止进行指令重排。
指令重排是指处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证各个语句的执行顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。
程序执行到volatile修饰变量的读操作或者写操作时,在其前面的操作肯定已经完成,且结果已经对后面的操作可见,在其后面的操作肯定还没有进行。
例3:
//线程1:
context = loadContext(); //语句1 context初始化操作
inited = true; //语句2
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);
因为指令重排序,有可能语句2会在语句1之前执行,可能导致context还没被初始化,而线程2中就使用未初始化的context去进行操作,导致程序出错。
这里如果用volatile关键字对inited变量进行修饰,就不会出现这种问题了。
例4:
int a = 10; //语句1
int r = 2; //语句2
a = a + 3; //语句3
r = a*a; //语句4
这段代码有4个语句,那么可能的一个执行顺序是:
语句2–》语句1–》语句3–》语句4
那么可不可能是这个执行顺序呢: 语句2–>语句1–>语句4 --> 语句3
不可能,因为处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1会在Instruction 2之前执行。
3.3 volatile的底层原理
volatile 关键字修饰的变量会存在一个“lock:”的前缀。
Lock 前缀,Lock 不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对 CPU 总线和高速缓存加锁,可以理解为 CPU 指令级的一种锁。
同时该指令会将当前处理器缓存行的数据直接写会到系统内存中,且这个写回内存的操作会使在其他 CPU 里缓存了该地址的数据无效。
JVM层面的内存屏障
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad | Load1;LoadLoad;Load2 | 确保Load1的读取操作在Load2及后续所有读取操作之前执行 |
StoreStore | Store1;StoreStore;Store2 | 在Store2及其后的写操作之前,保证Store1的写操作已刷新到主内存 |
LoadStore | Load1;LoadStore;Store2 | 在Store2及其后的写操作之前,保证Load1的读操作已结束 |
StoreLoad | Store1;StoreLoad;Load2 | 保证Store1的写操作已刷新到主内存后,Load2及之后的读写操作才会执行 |
volatile 的底层实现原理是内存屏障,Memory Barrier(Memory Fence)
- 在每个volatile写操作的前面插入一个StoreStore屏障
- 在每个volatile写操作的后面插入一个StoreLoad屏障
- StoreStore屏障将保障上面所有的普通写操作结果在volatile写之前会被刷新到主内存->普通写操作对其他线程可见
- StoreLoad屏障的作用是避免volatile写操作与后面可能有的volatile读/写操作重排序
- 在每个volatile读操作的前面插入一个LoadStore屏障
- 在每个volatile读操作的后面插入一个LoadLoad屏障
- LoadStore屏障用来禁止编译器把上面的volatile读操作与下面的普通读写操作重排序
总之: volatile只能保证可见性和有序性但不能保证原子性,原子性需要通过Synchronized这样的锁机制实现
4. volatile和synchronized的区别
- volatile本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取; synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
- volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的。
- volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性。
- volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。
- volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化
5. synchronized修饰静态方法和普通方法的区别
synchronized具有同步功能,是一种互斥锁,锁的是对象,synchronized修饰普通方法时,锁对象是this对象。修饰静态方法时,锁对象是字节码文件对象。
synchronized可以用来修饰代码块和方法。
synchronized可以保证原子性,有序性,可见性。
synchronized的缺点:
- synchronized底层是由jvm实现,因此不能手动控制锁的释放,不如lock锁灵活,synchronized修饰的方法一旦出现异常,jvm保证锁会被释放(lock锁需要在finally中释放)。
- synchronized是非公平锁,不保证公平性。
5.1 synchronized修饰普通方法
public synchronized void run() {
System.out.println(1);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(2);
}
使用同一对象访问
Demo demo = new Demo();
new Thread(() -> demo.run()).start();
new Thread(() -> demo.run()).start();
那么结果是同步的
1
2
1
2
如果使用不同的对象访问
Demo demo = new Demo();
new Thread(() -> demo.run()).start();
Demo demo2 = new Demo();
new Thread(() -> demo2.run()).start();
那么结果可能不会同步,因为synchronized修饰普通方法时锁对象是this对象,而使用两个对象去访问,不是同一把锁。
1
1
2
2
5.2 synchronized修饰静态方法
public static synchronized void run() {
System.out.println(1);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(2);
}
使用不同的对象访问
Demo demo = new Demo();
new Thread(() -> demo.run()).start();
Demo demo2 = new Demo();
new Thread(() -> demo2.run()).start();
可以看到结果是同步的,因为当修饰静态方法时,锁对象是class字节码文件对象,而两个对象是同一个class文件,所以使用的是一个锁。
1
2
1
2
5.3 synchronized修饰代码块
与同步方法不同的是,同步代码块是显式声明资源。
private void lock() {
synchronized (this.getClass()) {
int i = 0;
do {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(this + " is locked " + i);
} while (i++ < 10);
}
}
这里synchronize的括号里是一个对象。
- 当括号里是this时,锁的是当前调用方法的对象。
- 当括号里是一个Class对象时,锁的是这个类,也就是同一个类的实例,任意时刻只会有一个线程能获得资源。
上述方法调用结果:
多线程.对象锁.ClassLock@4d47348f is locked 0
多线程.对象锁.ClassLock@4d47348f is locked 1
...
多线程.对象锁.ClassLock@4d47348f is locked 10
多线程.对象锁.ClassLock@4e7eaa82 is locked 0
多线程.对象锁.ClassLock@4e7eaa82 is locked 1
...
多线程.对象锁.ClassLock@4e7eaa82 is locked 10
问题1:锁机制如何保证共享变量可见性?
答:当某一个线程进入synchronized代码块前,线程会获得锁,清空工作内存,从主内存拷贝共享变量的最新值到工作内存成为副本,之后执行代码,将修改后的副本的值刷新回主内存中,最后线程释放锁。而获取不到锁的线程会阻塞等待,所以变量的值肯定一直都是最新的
问题2:volatile如何保证共享变量可见性?
答:在工作内存中,每次使用volatile变量前必须先从主内存刷新变量的最新值到工作内存;
在工作内存中,每次修改volatile变量后都必须立刻同步回主内存中,用于保证其他线程可以看到当前线程对volatile变量所做的修改。
总结:
对象锁: synchronized(object) 锁住的是对象,每个对象自己拥有一个锁。
类锁: synchronized(Class) 锁住的是类,也就是同一个类的实例,任意时刻只会有一个线程能获得资源。
具体有关synchronized修饰代码块的使用实例可以参考对象及变量的并发访问:synchronized修饰代码块(六)