JMM(Java内存模型)
由于各种CPU指令集(CPU高速缓存),操作系统对内存的控制存在差异,JVM通过创建一种规范来屏蔽这种差异,这个就是JMM,JMM分为两个区域,工作内存和主内存,JMM规定所有变量(包含实例字段,静态字段,构成数组对象的元素,不包括局部变量和方法参数,因为这两个是线程私有的)都存储在主内存中 ,然后每个线程会有自己的工作内存 ,线程对变量的所有操作都必须在工作内存中执行;
注:如果变量是引用类型,那么线程中只会复制变量的引用,而不会复制变量中的所有对象,先拿到变量的引用,然后在根据引用去工作内存中取数据
重排序
在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排序
- 编译器优化重排序,在不改变单线程程序语义的前提下,重新排序(多线程下可能有问题)
- 指令级并行,如果指令直接没有依赖关系,处理器会将多条指令重叠执行
- 内存重排序,由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行
Happens-Before
-
程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
保证在一个线程中,前面的代码先执行,后面后执行
-
监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
保证被锁的代码执行完释放锁之后,其他线程才能加锁
-
volatile变量规则:对一个volatile变量的写,happens-before于任意后续对这个volatile变量的读。
保证volatile的可见性
-
传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
保证程序的逻辑顺序
-
start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
-
join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
单例模式双重检查锁
public class Singleton {
private static Singleton singleton;
private Singleton(){}
public static Singleton getInstance(){
if(singleton == null){ // 1
synchronized (Singleton.class){ // 2
if(singleton == null){ // 3
singleton = new Singleton(); // 4
}
}
}
return singleton;
}
}
上面的单例创建是有问题的;原因如下:
创建对象过程,实例化一个对象要分为三个步骤:
1、分配内存空间;2、初始化对象;3、将内存空间的地址赋值给对应的引用
但是由于重排序的缘故,步骤2、3可能会发生重排序,其过程如下:
1、分配内存空间;2、将内存空间的地址赋值给对应的引用;3、初始化对象
所以变量singleton必须加volatile关键字,禁止重排序
CAS
CAS: compareAndSet/compareAndSwap,是一种乐观锁,乐观锁认为并发是不常存在的(偶尔有并发),只有不断尝试,直到成功为止就可以了
CAS操作过程: CAS(V,O,N),包含三个值分别为:V 内存地址存放的实际值;O 预期的值(旧值);N 更新的新值;当V和O相同时,即内存中值和预期值相同,表示V没被修改过,这是把V的值修改为N是没问题的,CAS成功;反之,内存中的值和预期值不同时,说明V已经被别的线程修改过了,CAS失败;需要重新获取内存中的值然后重新CAS,直到成功
CAS存在的问题:
1、ABA问题
ABA问题,如果变量从A->B->A 变量版本发生了变化,但是CAS操作无法识别,解决思路添加版本号,每次更新版本号加1
2、自旋问题
循环环开销时间大,如果CAS一直不成功,就会一直占用线程,造成资源浪费
3、只能保证一个共享变量的原子操作
如果要同时修改多个变量,保证多个变量的一致性,CAS操作无法解决,需要通过锁来实现
synchronized
synchronized锁的是什么,怎么锁的
Java中实现锁需要哪些东西
- 锁对象:记录哪个线程获取到了锁,并让其他线程等待,
- 锁状态:记录当前锁的状态,如果锁可以重入,还需要记录重入次数
- 未获取到锁的进程怎么处理
- 锁释放后如何唤醒阻塞的线程
synchronized只能锁Object及子类,(8大基本类型锁不了)
- 对于普通同步方法,锁是当前实例对象。
- 对于静态同步方法,锁是当前类的Class对象。
- 对于同步方法块,锁是 synchronized 括号里配置的对象。
Object头文件会在内存中开辟空间,Object中有一个MarkWord存储对象运行时的数据
锁状态 | 25bit | 4bit | 1bit | 2bit | |
---|---|---|---|---|---|
23bit | 2bit | 是否偏向锁 | 锁标志 | ||
无状态 | 对象的hashCode | 分代年龄 | 0 | 01 | |
偏向锁 | 线程ID | Epoch | 分代年龄 | 1 | 01 |
轻量级锁 | 指向栈中锁记录的指针 | 00 | |||
重量级锁 | 指向重量级锁的指针 | 10 | |||
GC标记 | 空 | 11 |
为什么任何对象都可以实现锁
- 首先,Java 中的每个对象都派生自Object 类,而每个Java Object 在JVM 内部都有一个native 的C++对象oop/oopDesc 进行对应。
- 线程在获取锁的时候,实际上就是获得一个监视器对象(monitor),monitor 可以认为是一个同步对象,所有的Java 对象是天生携带monitor。
无锁 --> 偏向锁 --> 轻量级锁 --> 重量级锁
偏向锁
通过统计发现,大部分情况下锁不仅不存在竞争,而且锁总是由同一个线程多次获得,为了让锁获取的代价更低,减少不必要的CAS操作,引入了偏向锁;JDK1.6之后默认开启偏向锁;我们可以通过参数启用或禁用偏向锁
- 开启偏向锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
- 关闭偏向锁:-XX:-UseBiasedLocking=false
偏向锁获取锁过程
- 检查MarkWord是否偏向锁,锁标志为01,是否偏向锁为1;
- 如果是偏向锁,判断当前线程ID与MarkWord中的线程ID是否一致;如果一致执行5,否则执行3
- 如果线程ID不一致,通过CAS竞争锁,竞争成功后修改MarkWord中的线程ID,否则执行4;
- 通过CAS竞争锁失败,证明当前存在多线程竞争情况,到达全局安全点(获得偏向锁的线程被挂起,不执行);然后偏向锁升级为轻量级锁
- 执行同步代码
释放锁过程
偏向锁的释放采用了一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。偏向锁的撤销需要等待全局安全点(这个时间点是上没有正在执行的代码)。其步骤如下:
- 暂停拥有偏向锁的线程,判断锁对象是否还处于被锁定状态;
- 撤销偏向锁,恢复到无锁状态(01)或者轻量级锁的状态;
轻量级锁
在实际场景中,会存在这种场景,多个线程交替执行同步代码(存在锁竞争,但是不激烈),或者同步代码很快就可以执行完,这种场景下,重量级锁的开销就会非常大;所以JDK引入了轻量级锁
轻量级锁加锁
- 判断当前对象是否处于无锁状态(锁标志01,偏向锁0),如果是无锁状态,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word;否则执行步骤3
- 然后线程尝试通过CAS将对象头中的Mark Word替换为指向锁记录的指针,CAS成功表示获取到锁,把锁状态修改为00(轻量级锁),然后执行同步代码块,CAS失败则执行步骤3
- 通过自旋CAS修改对象的Mark Word(通过自旋获取锁),有次数限制,超过一定次数如果没有获取到锁,会导致锁膨胀成重量级锁
轻量级锁解锁
轻量级锁解锁是,会使用CAS将Displaced Mark Word替换为原来的对象头,如果成功表示没有锁竞争,失败会导致锁膨胀;
重量级锁
重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高
锁的优缺点对比
锁类型 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法(不加锁)相比仅存在纳秒级的差距 | 如果线程存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块的场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 如果线程始终竞争不到锁,会自旋消耗CPU | 追求响应时间,同步块执行非常快 |
重量级锁 | 线程竞争不会自旋,不会消耗CPU | 线程阻塞,响应非常慢 | 追求吞吐量,同步块执行很慢 |
volatile
Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。
通俗的就,volatile修饰的变量,Java可以保证这个变量所有线程拿到值的时候是一致的,如果某个线程更新了这个变量,其他线程可以立马看到这个更新,这就是所谓的线程可见性。
原理:汇编指令中:在修改带有volatile 修饰的成员变量时,会多一个lock 指令
- Lock前缀的指令会引起处理器缓存写回内存;
- 一个处理器的缓存回写到内存会导致其他处理器的缓存失效;(缓存一致性协议MESI)
- 当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。
volatile变量自身具有下列特性。
- 可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
- 原子性:对任意单个volatile变量的读/写具有原子性(对volatile变量直接赋值 a = 1),但类似于i++这种复合操作不具有原子性。
volatile变量规则:对一个volatile变量的写,happens-before于任意后续对这个volatile变量的读。
volatile可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在JVM底层volatile是采用“内存屏障”来实现的。
上面那段话,有两层语义
- 保证可见性、不保证原子性
- 禁止指令重排序