Java关键字系列(一)-synchronized与volatile


Java中有许多关键字,比如synchronized,volatile,transient,final,static,native等等,在这里我想针对对这些关键字进行一个统一的了解,并做一个系列。

在这些关键字中与并发息息相关的就是synchronized和volatile,在了解这2个关键字首先必须了解一个模型就是JMM模型。但是在了解JMM模型前需要先了解下硬件内存架构。

硬件内存架构

大家都知道现代计算机CPU与内存处理速度相差是极大的。这样就会导致一个局面是处理器等内存的数据,这样就发挥不出来CPU的优势。所以我们CPU为了解决这种差距就使用了多级缓存架构。在我们的CPU中都是有L1,L2,L3三级缓存。将运算的数据从内存写到缓存中去,CPU把数据处理完成,再讲数据写回到主内存去。但这样有一个问题就是缓存不一致的问题,试想如果程序是高并发的就会出现多个CPU同时操作主内存导致不同处理器数据不一致的问题,CPU解决这个问题使用了MESI缓存一致性协议。

JMM模型

JMM全称叫做Java内存模型,他与JVM内存模型是不一样的,JVM内存模型是针对于内存区域的划分,而JMM模型是一种抽象概念,它描述的是一个变量在主内存与工作内存中的访问方式。下面就是JMM内存模型图。

JMM内存模型图

Q:从这张图我们可以简单的看出线程间是如何通信的。假设现在有一个变量a,我们要对其进行修改,修改完之后我们怎么告诉线程B我改了呢?

A:首先线程A会从主内存中取出变量a,复制到工作内存,然后在工作内存中进行修改,修改完之后将数据同步回主内存,线程B再从主内存中取值。这就是线程间通信的基本过程。

其实在线程间通信,主要依赖于8种操作。

操作 作用域 功能
read 读取 主内存 它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用
load 加载 工作内存 它把read操作从主内存中得到的变量值放入工作内存的变量副本中
user 使用 工作内存 它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要
使用到变量的值的字节码指令时将会执行这个操作
assign 赋值 工作内存 它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给
变量赋值的字节码指令时执行这个操作
store 存储 工作内存 它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用
write 写入 主内存
lock 锁定 主内存 它把一个变量标识为一条线程独占的状态
unlock 解锁 主内存 它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定

JMM主要解决的问题是原子性,可见性,有序性

原子性

原子性是指一个操作不可被中断,即使是多线程环境下,他的操作也是不可以中断,比如大家都知道i++就不是原子性的,因为简单的i++代码在底层字节码层面是有三步操作的(下面代码有所说明)。他们会在这3步操作中发生线程时间片轮转调度机制在3步中发生,它的i++操作就被打断了。这就违反了原子性。

public class test {
    public static void main(String[] args) {
        int i=0;
        i++;
    }
}

// javap 反编译 对应字节码
public class com.dm.jmm.util.test {
  public com.dm.jmm.util.test();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: iconst_0
       1: istore_1
       2: iinc          1, 1
       5: return
}

像i++这种代码在多线程环境下运行是有问题的。比如在线程A执行了istore_1的时候线程轮转到线程B去执行i++,并且线程B已经执行完了i++操作此时i=1,线程切换回A继续操作iinc,最后i还是1,就导致了bug的产生。

private static int counter = 0;
public static void main(String[] args) {
    for (int i = 0; i < 10; i++) {
        Thread thread = new Thread(()->{
            for (int j = 0; j < 1000; j++) {
                counter++;
            }
        });
        thread.start();
    }
    try {
        Thread.sleep(3000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    System.out.println(counter);
}

上诉代码你执行无数次产生的结果都是不一致的,而且不一定为10000,这里即证明了i++不是原子性的,怎么解决在下面会有介绍。

9324

Process finished with exit code 0

可见性

单线程模型下不存在可见性问题,可见性问题必然是多线程下产生的。

在上面的JMM模型中,如果线程A从主内存拿走一个变量并在工作内存中进行了写操作且没同步到主内存,这时候线程B也从主内存拿走了这个变量,但此时这个变量值是线程A没修改之前的值。从这个例子中可以看出线程A修改的值线程B不可见。

下面是验证代码,运行代码会发现程序不会停止,说明了不可见,但在while(!flag)里面加上System.out.println发现又可见了,很奇怪,个人觉得是由于System.out.println里面含有同步块的问题。

public class test {
    private static boolean flag = false;
    public static void main(String[] args) {
        Thread a = new Thread(() -> {
            while (!flag) {
            }
            System.out.println("此时可见");
        });
        a.start();
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        Thread b = new Thread(() -> {
            flag = true;
        });
        b.start();
    }
}

有序性

在了解有序性之前需要首先了解一个现象叫指令重排,

指令重排

指令重排简单来说有的代码JVM结果不变的情况下会自动优化,使本来是1,2,3执行的代码优化为2,1,3等等。虽然说指令重排是在保证结果不变的情况下来产生的这里会涉及到as-if-serial语义happens-before原则,但在特定情况下就会产生差别,使结果的不确定性使你的程序无法控制。下面贴一个代码来证明指令重排的现象。

@Slf4j
public class test {
    private  static int x = 0, y = 0;
    private  static int a = 0, b = 0;
    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        for (;;){
            i++;
            x = 0; y = 0;
            a = 0; b = 0;
            Thread t1 = new Thread(new Runnable() {
                public void run() {
                    shortWait(10000);
                    a = 1;
                    x = b;
                }
            });

            Thread t2 = new Thread(new Runnable() {
                public void run() {
                    b = 1;
                    y = a;
                }
            });

            t1.start();
            t2.start();
            t1.join();
            t2.join();

            String result = "第" + i + "次 (" + x + "," + y + ")";
            if(x == 0 && y == 0) {
                System.out.println(result);
                break;
            } else {
                log.info(result);
            }
        }

    }

    /**
     * 等待一段时间,时间单位纳秒
     * @param interval
     */
    public static void shortWait(long interval){
        long start = System.nanoTime();
        long end;
        do{
            end = System.nanoTime();
        }while(start + interval >= end);
    }
}


// 结果
......
......
11:43:28.814 [main] INFO com.dm.jmm.util.test -656298(0,111:43:28.814 [main] INFO com.dm.jmm.util.test -656299(0,111:43:28.814 [main] INFO com.dm.jmm.util.test -656300(0,111:43:28.815 [main] INFO com.dm.jmm.util.test -656301(0,111:43:28.815 [main] INFO com.dm.jmm.util.test -656302(0,1)
第656303(0,0

从这段代码可以推测出结果应该是(0,1); (1,0); ,(1,1)三个结果,0,0这个结果理论上是不会产生的,但执行后发现程序终止了(0,0)结果产生了。(0,0)的产生必然发生了指令重排,因为不发生指令重排a,b必然有一个为1,不管线程怎么走,a,b必然为1那,x,y也至少有一个为1,出现了(0,0)则代表a=1和x=b之间发生了重排序或者b=1和y=a发生了重排序。

执行结果不可控在代码里面是很恐怖的,as-if-serial语义保证了单线程执行结果不能被改变,happens-before原则保证了正确同步的多线程执行结果不能被改变,JVM会根据2个原则进行指令重排保证语义的正确。

  • as-if-serial语义

    不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。

    为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

  • happens-before原则

    从JDK 5开始,Java使用新的JSR-133内存模型,提供了happens-before原则来辅助保证程序执行的原子性、可见性以及有序性的问题,它是判断数据是否存在竞争、线程是否安全的依据

    happens-before 原则内容如下

    1. 程序顺序原则,即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行。
    2. 锁规则 解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。
    3. volatile规则 volatile变量的写,先发生于读,这保证了volatile变量的可见性,简单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。
    4. 线程启动规则 线程的start()方法先于它的每一个动作,即如果线程A在执行线程B的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量的修改对线程B可见
    5. 传递性 A先于B ,B先于C 那么A必然先于C
    6. 线程终止规则 线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功返回后,线程B对共享变量的修改将对线程A可见。
    7. 线程中断规则 对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。
    8. 对象终结规则对象的构造函数执行,结束先于finalize()方法

解决原子性问题

JVM自身是对八大基本数据类型读写操作提供原子性的,比如int x = 9;其余操作的原子性可以通过synchronized和ReentrantLock解决或者通过Unsafe魔法类的CAS来解决。含有Atomic的类,比如AtomicInteger底层就大量的运用到了Unsafe.针对上面原子性的代码进行原子性的调整。

private static Object object = new Object();
private static int counter = 0;
public static void main(String[] args) {
    for (int i = 0; i < 10; i++) {
        Thread thread = new Thread(()->{
            for (int j = 0; j < 1000; j++) {
                synchronized (object) {
                    counter++;
                }
            }
        });
        thread.start();
    }
    try {
        Thread.sleep(3000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    System.out.println(counter);
}

// 执行结果
10000

Process finished with exit code 0

这段代码与上面的代码唯一不一样的地方就是在counter自增的地方加了一个synchronized同步块。而这同步块解决了原子性问题。

解决可见性问题

一般解决可见性问题可以使用volatile来解决。

同样贴上代码

private volatile static boolean flag = false;
public static void main(String[] args) {
    Thread a = new Thread(() -> {
        while (!flag) {
        }
        System.out.println("此时可见");
    });
    a.start();
    try {
        Thread.sleep(500);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    Thread b = new Thread(() -> {
        flag = true;
    });
    b.start();
}

这段代码与上面的代码唯一不一样的地方就是flag变量加了一个volatile关键字。而这解决了可见性问题。

解决有序性问题

解决有序性问题同样也可以使用volatile关键字。这里就不贴代码了。在上诉代码里面给x,y,a,b加个volatile就OK了、

volatile

上面提到最多的就是volatile关键字。volatile关键字解决了有序性问题,可见性问题,但是他没有解决原子性问题。你可以用上诉counter自增代码测试一下。结果和不加volatile是一致的,不确定结果。

volatile总共有以下2个作用

  • 被volatile修饰的共享变量对所有线程总数可见的
  • 禁止指令重排

volatile如何禁止指令重排

在这之前得首先了解一个叫做内存屏障的概念。

什么叫内存屏障?

不同的硬件实现内存屏障的方式是不一样的。Java内存模型会屏蔽这种差异,JVM提供了四种内存屏障。

  • LoadLoad屏障:第一次load操作进工作内存才可以进行第二次load操作。
  • LoadStore屏障:load操作进工作内存才可以进行store操作。
  • StoreStore屏障:第一次store操作完成后并且刷新到主内存才可以进行第二次的store操作。
  • StoreLoad屏障:store操作完成后并且刷新到主内存才可以进行load操作。

而在指令之间插入了内存屏障就会禁止在内存屏障前后的指令执行重排序优化解决了有序性问题,而且会强制刷新CPU缓存数据解决了可见性问题。

volatile内存语义

第一次操作 第二次操作:普通读 第二次操作:普通写 第二次操作:volatile读 第二次操作:volatile写
普通读 LoadStore
普通写 StoreStore
volatile读 LoadLoad LoadStore LoadLoad LoadStore
volatile写 StoreLoad StoreStore

DCL(Double Check Lock)双重检查

单例模式想必都很熟悉,下面是一个常见的单例模式

public class DoubleCheckLock {
    private static Object instance;
    public static Object getInstance(){
        if (instance==null){
            synchronized (DoubleCheckLock.class){
                if (instance == null){
                    instance = new Object();
                }
            }
        }
        return instance;
    }
}

双重检查这里就不必说了,但这段代码有一个致命的地方,就是在instance = new Object();

在new 一个对象通常是有3个步骤

  1. 分配对象内存空间
  2. 初始化对象
  3. 设置对象指向刚分配的内存地址

而对象在设置对象指向刚分配的内存地址这一步的时候对象就不是空了,而这3步不做处理会发生指令重排,如果重排变成了1->3->2,又是高并发场景去拿这个单例就会出现空指针异常(即使这种出现的可能性极小但理论上是会发生的)

解决这个问题可以再单例上加上一个volatile解决,private volatile static Object instance;

volatile如何解决可见性问题

指令遇到了内存屏障会出现强制刷新,缓存数据会和主存进行同步。会通过缓存一致性协议进行同步。

synchronized

syncronized是一个Java同步器是内置锁,synchronized是一种对象锁(锁的是对象而非引用),作用粒度是对象,可以用来实现对临界资源的同步互斥访问,是可重入的。

多线程编程下,我们在访问一个临界资源,由于线程执行的过程是不可控的,所以需要采用同步机制来协同对对象可变状态的访问,这个时候我们就需要同步器。

Java中有2种方式来解决线程并发安全问题。synchronized,lock

synchronized原理

synchronized是基于JVM内置锁实现,通过内部对象Monitor(监视器锁)实现,监视器锁的实现依赖底层操作系统的Mutex lock(互斥锁)实现,它是一个重量级锁性能较低。synchronized在1.5之后版本做了重大的优化,如锁粗化、锁消除,锁膨胀升级等来减少锁操作的开销,synchronized的并发性能已经基本与Lock持平。

Monitor是一种同步机制,synchronized也被称为内置锁原因就是Monitor存在于每一个对象中,当对象头MarkWord的锁标识位为10的时候,会有一个指针指向Monitor内存地址的起始位。

下图为32位虚拟机对象头的bit位分配

从上图可以看出内置锁共有4种状态无锁,偏向锁,轻量级锁,重量级锁。

锁的膨胀升级

内置锁有一个锁的膨胀升级过程,他会从无锁状态->偏向锁->轻量级锁->重量级锁。而且锁只会升级不会降级。

  • 偏向锁

    一个对象获得了锁,此时就是偏向锁,如果这个线程在想拿锁,无需再申请锁。在没有锁竞争的情况下,会使用偏向锁。

  • 轻量级锁

    在多个线程交替执行同步块的时候,会升级到轻量级锁。

  • 自旋锁

    当线程竞争激烈,多个线程请求同步块的时候,轻量级锁失效的情况下,不会马上升级为重量级锁,而是升级为自旋锁,自旋锁会计算自旋次数,如果自旋次数达到阈值就会升级到重量级锁。

  • 重量级锁

    锁竞争激烈自旋锁达到阈值,升级重量级锁。

锁消除

Java虚拟机在JIT编译时对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁

锁粗化

Java虚拟机会把几个连续的同步块合并为一个,将原来的锁粗化

MESI 缓存一致性协议

MESI 是指四种状态的首字母。每个Cache line有4个状态,可用2个bit表示,它们分别是:M(修改),E(独享),S(共享),I(失效)

状态 描述 监听
M(修改) Cache line有效,数据被修改了,和内存中的数据不一致,数据在本Cache中 缓存行必须时刻监听所有试图读该缓存行相对就主存的
操作,这种操作必须在缓存将该缓存行写回主存并将状态变成S(共享)状态之前被延迟执行。
E(独享) Cache line有效,数据和内存中的数据一致,数据在本Cache中 缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有
这种操作,该缓存行需要变成S(共享)状态。
S(共享) Cache line有效,数据和内存中的数据一致,数据在很多Cache中 缓存行也必须监听其它缓存使该缓存行无效或者独享
该缓存行的请求,并将该缓存行变成无效(Invalid)。
I(失效) 该Cache line无效

举个例子:

假设:2个线程T1,T2,一个变量a = 1;被volatile修饰会共享变量

  1. 现在T1线程读了a=1,T1给a标记一个状态E(独享)
  2. 现在T1线程读了a=1,T1给a标记一个状态E(独享)
  3. 然后现在T1,T2都要对a进行修改,2个线程不可以直接修改,他们都对各自的缓存行进行加锁,加锁成功后才可以修改,修改后状态S->M,那么问题来了,他们之间的加锁2个线程之间都互相不知道,那这加锁没啥用?线程对缓存行加锁时候会向外部发一个消息告诉别的线程我已经加锁了,你们别加了,那如果2方同时加锁,同时发消息呢?他们发消息必定会经过总线,在总线这总线会对2方进行一个裁决(总线裁决)谁加锁成功,加锁成功的才能进行修改,另一方只能将自己的数据给丢掉状态S->I

如果一个变量太大,一个缓存行放不下,放在了多个缓存行,为了保证原子性,加锁的时候加不了,此时CPU就直接升级为总线锁


文章作者: dm
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 dm !
评论
 上一篇
SpringIOC容器加载流程和源码剖析 SpringIOC容器加载流程和源码剖析
前言说道SpringIOC大家想到的都是控制反转,依赖注入。控制反转是将Bean的创建过程由Spring接手,由Spring创建而不需要我们自己来创建。而依赖注入就是实现控制反转的方式。 SpringIOC基本概念BeanFactorySp
2022-01-15
下一篇 
ConcurrentHashMap1.7和1.8对比与线程安全源码解析 ConcurrentHashMap1.7和1.8对比与线程安全源码解析
ConcurrentHashMap与HashMap在Api,参数,数据结构上面基本都是类似的,实现原理也基本都是一致的在这里就不做过多的说明了,若不清楚可以看下另一篇HashMap的对比博文HashMap1.7和1.8对比与源码解析:Con
2021-11-16
  目录