JMM 定义了一套在线多线程读写共享数据实(成员变量、数组)时,对数据的可见性、有序性和原子性的规则和保障
原子性(Atomic)就是不可分割的意思,是指在进行一系列操作的时候这些操作要么全部执行要么全部不执行,不存在只执行一部分的情况。
原子操作的不可分割有两层含义:
java 给出的方案就是 synchronized(同步关键字)
synchronized(对象){
}
不过在使用synchronized时,会大大降低程序的并发性,谨慎使用
例:
package JMM;
public class demo1 {
static int c = 0;
static Object obj = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
synchronized (obj) {
c++;
}
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
synchronized (obj) {
c--;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(c);
}
}
当代码中存在了synchronized同步关键字,monitor它会新建出三个线程Owner、Entrylist和waitSet
T1被Owner锁定,T2线程开始在EntryList区域等待
T1执行完毕,在EntryList中的T2线程开始被Owner锁定执行
可以将Obj想象成一个房间,线程t1、t2是两个人
当t1执行synchronized(obj)时就可以看成,t1进入了obj房间,然后锁住了这个门,在房间内执行代码
而在t1执行代码的期间,t2也执行synchronized(obj)了,它想进入obj房间,但是发现房间被锁住了,于是只能在门外找个凳子(EntryList)等待。
当t1执行完synchronized{}块内的代码后,它才会解开obj房间里门的锁,从obj房间出来。这时候t2线程才可以进入obj房间,反锁住门,执行它的代码
注意,不同线程进行synchronized同步时必须锁的是同一个对象,如果锁的不是同一个对象,就好比两个人分别进了不同的房间,达不到同步的效果
一个线程对主内存的修改可以及时的被其他线程观察到。
看一个例子:
main线程对run变量的修改对于t线程不可见,导致了t线程无法停止:
/**
* 可见性
*/
public class demo {
static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while (flag) {
System.out.println("测试");
}
});
t1.start();
Thread.sleep(1000);
flag = false;
}
}
使用的是JDK8环境,若是JDK11或以上则不会发生这种问题了
分析:
1.2.2 解决方法
volatile static boolean flag = true;
tips: synchronized语句块既可以保证代码的原子性,也同时保证块内变量的可见性。但缺点是synchronizad是属于重量级操作(降低并发能力),性能相对更低
package JMM;
/**
* 有序性
*/
public class demo2 {
int num = 0;
boolean ready = false;
static class Result{
int num = 0;
}
public void actor1(Result r){
if (ready){
r.num = num +num;
}else {
r.num = 1;
}
}
public void actor2(Result r){
num = 2;
ready = true;
}
}
Result是一个对象,有一个属性c用来保存结果。
两种运行情况:
现象:指令重排
在做出关键性判断的一步上添加 volatile 关键字,保证线程之间对数值更改的可见性
volatile boolean ready = false;
同一个线程内,JVM会在不影响正确性的前提下,可以调整语句的执行顺序
static int i;
static int j;
// 在某个线程内执行如下赋值操作
i = ...;// 一种较为耗时的操作
j = ...;
看得到是,不论先执行i
还是先执行j
对最终结果都不会产生影响。
这种特性被称之为【指令重排】,多线程下的【指令重排】会影响正确性,例如著名的double-checked looking
final class Singleton{
private Singleton (){}
private static Singleton INSTANCE=null;
public static Singleton getInstance(){
if (INSTANCE==null){
synchronized (Singleton.class){
if (INSTANCE==null){
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
以上的实现特点是:
但在多线程环境下,上面代码是有问题的,主要原因在于 INSTANCE = new Singleton()对应的字节码为:
17: new #3 // class JMM/demo3
20: dup
21: invokespecial #4 // Method "<init>":()V
24: putstatic #2 // Field INSTANCE:LJMM/demo3;
其中 21、24两个步骤的顺序是不固定的,也许JVM会优化为:先将引用地址赋值给 INSTANCE 变量后,再执行构造方法,如果两个线程t1、t2按如下时间序列执行:
这时候,t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的将是一个未初始化完毕的单例
解决办法:
happens-before 规定了哪些写操作对其他线程的读操作可见,它是可见性与有序性的一套规则总结
public class demo4 {
static int x;
static Object m = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (m) {
x = 10;
}
},"t1");
t1.start();
new Thread(() -> {
synchronized (m) {
System.out.println(x);
}
},"t2").start();
}
}
CAS 即:Compare and Swap
它体现的一种乐观锁的思想,比如多个线程要对一个共享的整形变量执行+1 操作:
package JMM;
public class demo5 {
static int z = 0;
public static void main(String[] args) {
while (true) {
int o = z; // 比如拿到了当前值0
int n = o+1; // 在旧值 0 的基础上增加 1,正确结果是 1
/**
* 这时候如果别的线程把共享变量改成了 5,本线程的正确结果是 1 就作废了,这时候
* CAS 返回 false,重新尝试,直到:
* CAS 返回 true,表示我本线程做修改的同时,别的线程没有干扰
*/
if (CAS(o,n)){
// 成功,退出循环
break;
}
}
System.out.println(z);
}
private static boolean CAS(int o, int n) {
z = n;
if (o == z){
return true;
}
return false;
}
}
获取共享变量时,为了保证该变量的可见性,需要使用 volatile 修饰。
结合CAS和volatile可以实现无锁并发,适用于竞争不激烈、多核CPU的场景下。
底层保护机制:
例子:(直接使用Unsafe对象来进行线程安全保护)
package JMM;
import jdk.internal.misc.Unsafe;
import java.lang.reflect.Field;
public class demo6 {
public static void main(String[] args) throws InterruptedException {
DataContainer dc = new DataContainer();
int count = 5;
Thread t1 = new Thread(() -> {
for (int i = 0; i < count; i++) {
dc.increase();
}
});
t1.start();
t1.join();
System.out.println(dc.getData());
}
}
class DataContainer{
private volatile int data;
static Unsafe unsafe = null;
static long DATA_OFFSET = 0;
static{
try {
Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
theUnsafe.setAccessible(true);
unsafe = (Unsafe) theUnsafe.get("null");
}catch (NoSuchFieldException | IllegalAccessException e) {
throw new RuntimeException(e);
}
try {
DATA_OFFSET = unsafe.objectFieldOffset(DataContainer.class.getDeclaredField("data"));
} catch (NoSuchFieldException e) {
throw new RuntimeException(e);
}
}
/**
* 相加
*/
public void increase(){
int oldValue;
// 当cas修改成功后,会返回true,那时候会结束循环,否则将一直循环尝试修改
while(true){
// 获得共享变量旧值
oldValue = data;
// cas 尝试修改data为【旧值 +1】;如果期间旧值被别的线程改了,则返回false
if (unsafe.compareAndSetInt(this,DATA_OFFSET,oldValue,oldValue+1)){
return;
};
}
}
/**
* 相减
*/
public void decrease(){
int oldValue;
while(true){
oldValue = data;
if (unsafe.compareAndSetInt(this,DATA_OFFSET,oldValue,oldValue-1)){
return;
};
}
}
public int getData(){
return data;
}
}
juc(java.util.concurrent)中提供了原子操作类,可以提供线程安全的操作,例如:AtomicInteger、AtomicBoolean等,它们底层就是采用CAS技术 +volatile 来实现的。
Java HotSpot 虚拟机中,每个对象都有对象头(包含class指针和Mark Word)。
Mark Word平时存储这个对象的哈希码、分代年龄,当加锁时,这些信息就根据情况被替换为标记位、线程锁记录指针、重量级锁指针、线程ID等内容
如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。而一旦出现并发情况,那么就会升级为重量级锁
举个简单的例子: 学生A(t1)用课本占座,上了半节课,出门了(CPU时间到),回来一看,发现课本没变,说明没有竞争,继续上他的课。 如果这期间有其他学生B(t2)来了,会告知学生A(t1)有并发访问,线程A t1 随机升级为重量级锁,进入重量级锁的流程 而重量级锁就不是用课本占座这种小操作了。可以想象成在座位周围用一个铁栅栏上了锁围起来
static Object obj new Object();
public static void method1(){
synchronized(obj){
// 同步块A
method2();
}
}
public static void method2(){
synchronized(obj){
// 同步块B
}
}
每个线程的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的Mark Word
线程A | 对象Mark Word | 线程B |
---|---|---|
访问同步块A,把Mark复制到线程A的锁记录 | 01(无锁) | |
CAS修改Mark为线程1锁记录地址 | 01(无锁) | |
成功(加锁) | 00(轻量锁)线程1锁记录地址 | |
执行同步块A | 00(轻量锁)线程1锁记录地址 | |
访问同步块B,把Mark复制到线程A的锁记录 | 00(轻量锁)线程1锁记录地址 | |
CAS修改Mark为线程A锁记录地址 | 00(轻量锁)线程1锁记录地址 | |
失败(发现是自己锁的) | 00(轻量锁)线程1锁记录地址 | |
锁重入 | 00(轻量锁)线程1锁记录地址 | |
执行同步块B | 00(轻量锁)线程1锁记录地址 | |
同步块B执行完毕 | 00(轻量锁)线程1锁记录地址 | |
同步块A执行完毕 | 00(轻量锁)线程1锁记录地址 | |
成功(解锁) | 01(无锁) | |
01(无锁) | 访问同步块A,把Mark复制到线程2的 | |
01(无锁) | CAS 修改Mark为线程2锁记录地址 | |
00(轻量级)线程2锁记录地址 | 成功(加锁) | |
… | … |
如果在尝试加轻量级锁的过程中,CAS操作无法成功,这时一种情况就是有其他线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁
static Object obj = new Object();
public static void method1(){
synchronized(obj){
// 同步块
}
}
线程A | 对象Mark Word | 线程B |
---|---|---|
访问同步块A,把Mark复制到线程A的锁记录 | 01(无锁) | |
CAS修改Mark为线程A锁记录地址 | 01(无锁) | |
成功(加锁) | 00(轻量锁)线程1锁记录地址 | |
执行同步块A | 00(轻量锁)线程1锁记录地址 | |
执行同步块A | 00(轻量锁)线程1锁记录地址 | 访问同步块A,把Mark复制到线程2的 |
执行同步块A | 00(轻量锁)线程1锁记录地址 | CAS 修改Mark为线程2锁记录地址 |
执行同步块A | 00(轻量锁)线程1锁记录地址 | 失败(发现其他线程已经占用锁) |
执行同步块A | 00(轻量锁)线程1锁记录地址 | CAS修改Mark为重量级锁 |
执行同步块A | 10(重量级)重置锁指针 | 阻塞中 |
执行完毕 | 10(重量级)重置锁指针 | 阻塞中 |
失败(解锁) | 10(重量级)重置锁指针 | 阻塞中 |
释放重量级锁,唤起阻塞线程竞争 | 10(重量级)重置锁指针 | 阻塞中 |
10(重量级)重置锁指针 | 竞争重量锁 | |
10(重量级)重置锁指针 | 成功(加锁) | |
… | … |
在重量级锁竞争时,还可以使用自旋来进行优化,如果当前线程自旋成功(即使现在持锁线程已经退出了同步块,释放了锁),这时候当前线程就可以避免阻塞
在java6以后,自旋锁时自适应的。比如对象刚刚的一次自旋操作成功过,那么任务这次自旋成功的可能性会比较高,就多自旋几次,反之就少自旋,甚至不自旋。
线程1(cpu1上) | 对象Mark | 线程2(cpu2上) |
---|---|---|
10(重量锁) | ||
访问同步块,获取monitor | 10(重量锁)重置锁指针 | |
成功(加锁) | 10(重量锁)重置锁指针 | |
执行同步块 | 10(重量锁)重置锁指针 | |
执行同步块 | 10(重量锁)重置锁指针 | 访问同步块,获取monitor |
执行同步块 | 10(重量锁)重置锁指针 | 自旋重试 |
执行完毕 | 10(重量锁)重置锁指针 | 自旋重试 |
成功(解锁) | 10(重量锁) | 自旋重试 |
10(重量锁)重置锁指针 | 成功(加锁) | |
10(重量锁)重置锁指针 | 执行同步块 | |
… | … |
线程1(cpu1上) | 对象Mark | 线程2(cpu2上) |
---|---|---|
10(重量锁) | ||
访问同步块,获取monitor | 10(重量锁)重置锁指针 | |
成功(加锁) | 10(重量锁)重置锁指针 | |
执行同步块 | 10(重量锁)重置锁指针 | |
执行同步块 | 10(重量锁)重置锁指针 | 访问同步块,获取monitor |
执行同步块 | 10(重量锁)重置锁指针 | 自旋重试 |
执行同步块 | 10(重量锁)重置锁指针 | 自旋重试 |
10(重量锁)重置锁指针 | 阻塞 | |
… | … |
轻量级锁在没有竞争时,每次重入仍需要执行CAS操作。
java6中引入了偏向锁来做进一步优化;只有第一次使用CAS将线程ID设置到对象的Mark Word头,之后发现这个线程ID时自己的,就表示没有竞争,不用重新CAS
在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的,这种情况下,就会给线程加一个偏向锁。线程第二次到达同步代码块时,会判断此时持有锁的线程是否就是自己,如果是则正常往下执行。由于之前没有释放锁,这里也就不需要重新加锁。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能是非常不错的。 唯独就怕遭其他线程抢锁,因为需要撤销偏向(会STW)。重复争抢锁,就会导致性能下降
缺点:
同步代码块中尽量简短一点
将一个锁拆分为多个锁提高并发度 例如:
多次循环进入同步块不如同步块内多次循环
另外JVM可能会做如下优化,把多次append的加锁操作粗化一次(因为都是对同一个对象加锁,没比较重入多次)
new StringBuffer().append("a").append("b").append("c");
JVM会进行代码的逃逸分析,例如某个加锁对象时方法内局部变量,不会被其他线程锁访问到,这时候就会被即时编译器忽略掉所有同步操作
读写分离主要是为了将对数据库的读写操作分散到不同的数据库节点上。 这样的话,就能够小幅提升写性能,大幅提升读性能。
不论是使用哪一种读写分离具体的实现方案,想要实现读写分离一般包含如下几步: