15. JMM
请你谈谈你对Volate的理解
Volate是java虚拟机提供轻量级的同步机制
- 保证可见性
- 不保证原子性
- 禁止指令重排
JMM是什么
JMM: java内存模型,不存在的东西,概念!约定!
JMM就是围绕着多线程通信以及其相关的一系列特性而建立的模型。
关于JMM的一些同步的约定
- 线程解锁前,必须把共享变量立刻刷回主存
- 线程加锁前,必须读取主存中的最新值到工作内存中!
- 加锁和解锁必须是同一把锁
线程分为:工作内存///主内存
先store后write
操作 | 说明 |
---|---|
lock (锁定): | 作用于主内存的变量,把一个变量标识为线程独占状态 |
unlock (解锁): | 作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定 |
read (读取): | 作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用 |
load (载入): | 作用于工作内存的变量,它把read操作从主存中变量放入工作内存中 |
use (使用): | 作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到变量的值,就会使用到这个指令 |
assign (赋值): | 作用于工作内存中的变量,它把一个从执行引擎中接受到的值放入工作内存的变量副本中 |
store (存储): | 作用于主内存中的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的write使用 |
write (写入): | 作用于主内存中的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中 |
八种规则
不允许read和load、store和write操作之一单独出现。即使用了read必须load,使用了store必须write
不允许线程丢弃他最近的assign操作,即工作变量的数据改变了之后,必须告知主存
不允许一个线程将没有assign的数据从工作内存同步回主内存
一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是怼变量实施use、store操作之前,必须经过assign和load操作
一个变量同一时间只有一个线程能对其进行lock。多次lock后,必须执行相同次数的unlock才能解锁
如果对一个变量进行lock操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前,必须重新load或assign操作初始化变量的值
如果一个变量没有被lock,就不能对其进行unlock操作。也不能unlock一个被其他线程锁住的变量
对一个变量进行unlock操作之前,必须把此变量同步回主内存
问题: 程序不知道主内存中的值已经被修改过了
16. Volatile
- volatile只能修饰实例变量和类变量,而synchronized可以修饰方法,以及代码块。
保证可见性
Volatile实现内存可见性原理:
写操作时,通过在写操作指令后加入一条store屏障指令,让本地内存中变量的值能够刷新到主内存中
读操作时,通过在读操作前加入一条load屏障指令,及时读取到变量在主内存的值
PS: 内存屏障(Memory Barrier)是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。Java编译器也会根据内存屏障的规则禁止重排序
volatile可见性:是指当多个线程并发访问共享变量时,一个线程对共享变量的修改,其它线程能够立即看到。
那么volatile是如何保证可见性的,首先我们来简单看一下Java内存模型图:
假设有一个变量 static int vlt = 0;
那么首先在主内存中会一个vlt变量;同时在线程1和线程的2的线程内都有一个vlt 的副本,目的是为了提高执行效率,变量随线程结束消失。
如果此时线程1执行vlt=5;那么此时线程2读到的变量会是什么?
有可能是1也有能能是5,具体需要看线下2什么时候同步了住内存的变量.。
那加上 volatile static int vlt = 0;是如何保证2线程是读到的一定是5呢?
volatile 主要是利用了java的先行发生原则 (简单介绍先行发生原则:
在计算机科学中,先行发生原则是两个事件的结果之间的关系,如果一个事件发生在另一个事件之前,结果必须反映,即使 这些事件实际上是乱序执行的(通常是优化程序流程)。
)
volatile相关的规则:
对于一个volatile变量的写操作先行发生于后面对这个变量的读操作。
因此当线程1执行了vlt=5;写操作是必然先发生2线程读操作。即线程2从主内存读到的数据一定是线程1写过的数据那就是5。所以volatile主要利用了先行发生原则保证线程之间的可见性。
public class JMMDemo {
//不加volatile 程序就会死循环
//加上volatile 可以保证可见性
private volatile static int number = 0;
public static void main(String[] args) {
new Thread(()->{
while(number == 0){
}
}).start();
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
}
number = 1;
System.out.println(number);
}
}
- 不保证原子性
原子性: 不可分割
线程A在执行任务的时候.不能被打扰,也不能被分割,要么同时成功,要么同时失败
//测试不保证原子性
public class VDemo {
private volatile static int num = 0;
public static void main(String[] args) {
for (int i = 0; i < 20; i++) {
new Thread(()->{
for (int j = 0; j < 1000; j++) {
add();
}
}).start();
}
while (Thread.activeCount() > 2){
Thread.yield();
}
System.out.println(num);
}
public static void add(){
num++;
}
}
如果不加lock和synchronized,如何保证原子性
使用原子类,解决原子性问题
//测试不保证原子性
public class VDemo {
//原子类的int
private volatile static AtomicInteger num = new AtomicInteger(0);
// AtomicInteger 调用的是底层的 CAS
public static void main(String[] args) {
for (int i = 0; i < 20; i++) {
new Thread(()->{
for (int j = 0; j < 1000; j++) {
add();
}
}).start();
}
while (Thread.activeCount() > 2){
Thread.yield();
}
System.out.println(num);
}
public static void add(){
num.getAndIncrement();
}
}
这些类的底层都直接和操作系统挂钩 ! 在内存中修改值! UnSafe类是一个很特殊的存在
指令重排
什么是指令重排: 你写的程序,计算机并不是按照指定的的步骤执行
源代码—>编译器优化的重排–>指令并行也可能会重排—>内存系统也会重排 执行
处理器在进行指令重排的时候,考虑:数据之间的依赖性!
int x = 1;
int y = 2;
x += 5;
y = 2 * x;
我们期望的是: 1234, 2134, 1324
不可能是4123!
可能造成影响的结果
a b x y 这四个值默认都是0;
线程A | 线程B |
---|---|
x = a | y = b |
b = 1 | a = 2 |
正常的结果: x = 0; y = 0;但是可能由于指令重排,
线程A | 线程B |
---|---|
b = 1 | a = 2 |
x = a | y = b |
指令重排导致的诡异结果x = 2; y = 1;
volatile可以避免指令重排:
内存屏障: CPU指令, 作用:
- 保证特定的操作的执行顺序!
- 可以保证某些变量的内存可见性(利用这些特性volatile实现了可见性)
volatile 标注后 编译器生成的代码会不优化!
在这里插入图片描述
Volatile 是可以保证可见性, 不能保证原子性,由于内存屏障可以避免指令重排的现象产生 !
4.3 锁优化
synchronized是重量级锁,效率不高。但在jdk 1.6中对synchronize的实现进行了各种优化,使得它显得不是那么重了。jdk1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
锁主要存在四中状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。
注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。
4.3.1 自旋锁
线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。所以引入自旋锁。
所谓自旋锁,就是让该线程等待一段时间,不会被立即挂起,看持有锁的线程是否会很快释放锁。怎么等待呢?执行一段无意义的循环即可(自旋)。
自旋等待不能替代阻塞,虽然它可以避免线程切换带来的开销,但是它占用了处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好,反之,自旋的线程就会白白消耗掉处理的资源,它不会做任何有意义的工作,典型的占着茅坑不拉屎,这样反而会带来性能上的浪费。所以说,自旋等待的时间(自旋的次数)必须要有一个限度,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起。
自旋锁在JDK 1.4.2中引入,默认关闭,但是可以使用-XX:+UseSpinning开开启,在JDK1.6中默认开启。同时自旋的默认次数为10次,可以通过参数-XX:PreBlockSpin来调整;
如果通过参数-XX:preBlockSpin来调整自旋锁的自旋次数,会带来诸多不便。假如我将参数调整为10,但是系统很多线程都是等你刚刚退出的时候就释放了锁(假如你多自旋一两次就可以获取锁),你是不是很尴尬。于是JDK1.6引入自适应的自旋锁,让虚拟机会变得越来越聪明。
4.3.2 适应自旋锁
JDK 1.6引入了更加聪明的自旋锁,即自适应自旋锁。所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。它怎么做呢?线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。
有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测会越来越准确,虚拟机会变得越来越聪明。
4.3.3 锁消除
为了保证数据的完整性,我们在进行操作时需要对这部分操作进行同步控制,但是在有些情况下,JVM检测到不可能存在共享数据竞争,这是JVM会对这些同步锁进行锁消除。锁消除的依据是逃逸分析的数据支持。
如果不存在竞争,为什么还需要加锁呢?所以锁消除可以节省毫无意义的请求锁的时间。变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是对于我们程序员来说这还不清楚么?我们会在明明知道不存在数据竞争的代码块前加上同步吗?但是有时候程序并不是我们所想的那样?我们虽然没有显示使用锁,但是我们在使用一些JDK的内置API时,如StringBuffer、Vector、HashTable等,这个时候会存在隐形的加锁操作。比如StringBuffer的append()方法,Vector的add()方法:
public void test(){
Vector<Integer> vector = new Vector<Integer>();
for(int i = 0 ; i < 10 ; i++){
vector.add(i);
}
System.out.println(vector);
}
在运行这段代码时,JVM可以明显检测到变量vector没有逃逸出方法vectorTest()之外,所以JVM可以大胆地将vector内部的加锁操作消除。
4.3.4 锁粗化
在使用同步锁的时候,需要让同步块的作用范围尽可能小,仅在共享数据的实际作用域中才进行同步,这样做的目的是为了使需要同步的操作量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。
在大多数的情况下,上述观点是正确的。但是如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗化的概念。
锁粗话概念比较好理解,就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。如上面实例:vector每次add的时候都需要加锁操作,JVM检测到对同一个对象(vector)连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到for循环之外。
4.3.5 偏向锁
轻量级锁的加锁解锁操作是需要依赖多次CAS原子指令的。而偏向锁只需要检查是否为偏向锁、锁标识为以及ThreadID即可,可以减少不必要的CAS操作。
偏向锁
在没有实际竞争的情况下,还能够针对部分场景继续优化。如果不仅仅没有实际竞争,自始至终,使用锁的线程都只有一个,那么,维护轻量级锁都是浪费的。偏向锁的目标是,减少无竞争且只有一个线程使用锁的情况下,使用轻量级锁产生的性能消耗。轻量级锁每次申请、释放锁都至少需要一次CAS,但偏向锁只有初始化时需要一次CAS。
“偏向”的意思是,偏向锁假定将来只有第一个申请锁的线程会使用锁(不会有任何线程再来申请锁),因此,只需要在Mark Word中CAS记录owner(本质上也是更新,但初始值为空),如果记录成功,则偏向锁获取成功,记录锁状态为偏向锁,以后当前线程等于owner就可以零成本的直接获得锁;否则,说明有其他线程竞争,膨胀为轻量级锁。
偏向锁无法使用自旋锁优化,因为一旦有其他线程申请锁,就破坏了偏向锁的假定。
缺点
同样的,如果明显存在其他线程申请锁,那么偏向锁将很快膨胀为轻量级锁。
不过这个副作用已经小的多。
如果需要,使用参数-XX:-UseBiasedLocking禁止偏向锁优化(默认打开)。
4.3.6 轻量级锁
引入轻量级锁的主要目的是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁。轻量级锁主要使用CAS进行原子操作。
但是对于轻量级锁,其性能提升的依据是“对于绝大部分的锁,在整个生命周期内都是不会存在竞争的”,如果打破这个依据则除了互斥的开销外,还有额外的CAS操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢。
4.3.7 重量锁
重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock(互斥锁)实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。