【Java并发编程】01-内存模型与Synchronized、volatile关键字

并发问题的根源不在乎以下几个原因:可见性、原子性、有序性。Java常用Synchronized、volatile关键字来解决并发问题,在了解这两个关键字之前我们先来看看Java内存模型方便理解并发问题是如何产生的。

Java内存模型(JMM)

物理硬件内存模型和Java内存模型

  • 物理硬件内存模型

目前基于高速缓存的存储交互很好的解决了cpu和内存等其他硬件之间的速度矛盾,多核情况下各个处理器(核)都要遵循一定的诸如MSI、MESI等协议来保证内存的各个处理器高速缓存和主内存的数据的一致性。

物理内存模型

  • Java内存模型(JMM)

Java内存模型来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各个平台下都能达到一致的并发效果。
主内存: Java虚拟机规定所有的变量(不是程序中的变量)都必须在主内存中产生,为了方便理解,可以认为是堆区。
工作内存: Java虚拟机中每个线程都有自己的工作内存,该内存是线程私有的为了方便理解,可以认为是虚拟机栈。
主内存、工作内存与java内存区域中的java堆、虚拟机栈、方法区并不是一个层次的内存划分。这两者是基本上是没有关系的,上文只是为了便于理解,做的类比

Java内存模型

  • JMM如何保证并发编程?

Java内存模型围绕着并发过程中如何处理原子性、可见性和顺序性这三个特征来设计的
原子性(Automicity): 指一个操作是不可中断的,即使是多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。
可见性: 指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道这个修改。(volatile在JMM模型上实现MESI协议)
有序性: 指对于单线程的执行代码,执行是按顺序依次进行的。但在多线程环境中,则可能出现乱序现象,因为在编译过程会出现“指令重排”,重排后的指令与原指令的顺序未必一致。(保证有序性的关键字有volatile和synchronized,volatile禁止了指令重排序,而synchronized则由“一个变量在同一时刻只能被一个线程对其进行lock操作”来保证。)
指令重排: 可以保证串行语义一致,但是没有义务保证多线程间的语义一致,对于提高CPU处理性能是十分重要的
Happen-Before规则: (不能重排的指令)
程序顺序原则、volatile规则、锁规则...

  • JVM结构

Java程序的分配是在JVM虚拟机内存分配下完成的。(JMM是内存模型的规范,JVM则是实现)


JVM

解析 Synchronized 和 volatile 关键字

Synchronized

Synchronized除了原子性、可见性、有序性之外还有可重入性(一个线程可以重复申请锁)

  • Synchronized的用法

1、作用在实例方法:监视器锁(monitor)便是对象实例
2、作用在静态方法:监视器锁(monitor)便是对象的Class实例,Class数据存在方法区(永久代)
3、作用在代码块,监视器锁(monitor)便是括号起来的对象实例

  • 查看字节码

我们先创建下面例子,分别使用了Synchronized的三种用法

public void test0(){
    System.out.println("test0");
}

public synchronized void test1(){
    System.out.println("test1");
}

public static synchronized void test2(){
    System.out.println("test2");
}

public void test3() {
    synchronized (this) {
        System.out.println("test3");
    }
}

然后通过javap -v 反编译class文件,可以得到:

1、同步方法:

作用在方法上可以看到在flags中多了一个ACC_SYNCHRONIZED的标志,这标志用来告诉JVM这是一个同步方法,在进入该方法之前先获取相应的锁,锁的计数器加1,方法结束后计数器-1,如果获取失败就阻塞住,直到该锁被释放。

image

image

2、同步代码块

从反编译的同步代码块可以看到同步块是由monitorenter指令进入,然后monitorexit释放锁,在执行monitorenter之前需要尝试获取锁,如果这个对象没有被锁定,或者当前线程已经拥有了这个对象的锁,那么就把锁的计数器加1。当执行monitorexit指令时,锁的计数器也会减1。当获取锁失败时会被阻塞,一直等待锁被释放。

image

但是为什么会有两个monitorexit呢?其实第二个monitorexit是来处理异常的,仔细看反编译的字节码,正常情况下第一个monitorexit之后会执行goto指令,而该指令转向的就是22行的return,也就是说正常情况下只会执行第一个monitorexit释放锁,然后返回。而如果在执行中发生了异常,第二个monitorexit就起作用了,它是由编译器自动生成的,在发生异常时处理异常然后释放掉锁。

  • synchronized的底层实现

1、Java对象头

在理解底层实现之前先了解一下Java对象头和Monitor,在JVM中,对象分为三部分存在的:对象头、实例数据、对齐填充

image

实例数据: 存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐
对其填充: 不是必须部分,由于虚拟机要求对象起始地址必须是8字节的整数倍,对齐填充仅仅是为了使字节对齐
对象头: 在Hotshot虚拟机的对象头主要由Mark Word、Class Metadata Address组成。其中Mark Word存储对象的hashCode、锁信息或分代年龄或GC标志信息,Class Metadata Address 是类型指针指向对象的类元数据,JVM通过该指针确定该对象是那个类的实例。

1.1 Mark Word怎么存储锁信息?

JDK6之前只有两个状态:无锁、有锁(重量级锁),而在JDK6之后对synchronized进行了优化,新增了两种状态,总共就是四个状态:无锁状态、偏向锁、轻量级锁、重量级锁,其中无锁就是一种状态了。考虑到存储成本,Mark Word被设计成一个非固定的数据结构,它会根据对象的状态复用自己的存储空间,它可能随着运行状态变成下面4中数据:

image

最后两位存储锁的标志位,01是初始状态,未加锁。偏向锁存储的是当前占用此对象的线程ID;而轻量级则存储指向线程栈中锁记录的指针

2、Monitor

每一个锁都对应一个monitor对象,在HotSpot虚拟机中它是由ObjectMonitor实现的(C++实现)。每个对象都存在着一个monitor与之关联,对象与其monitor之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个monitor被某个线程持有后,它便处于锁定状态。

ObjectMonitor() {
    _header       = NULL;
    _count        = 0;  //锁计数器
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL; //处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; //处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

2、volatile

volatile保证被修饰的变量具有可见性、有序性。被volatile修饰的变量能够保证每个线程能够获取该变量的最新值,从而避免出现数据脏读的现象。

  • volatile的特性

可见性: volatile变量修改后,修改的值立即写入主存,并将其他线程工作空间该变量的缓存cache line置为无效。
有序性: volatile变量在读写操作时,保证前面的代码已经执行,后面的代码一定未执行。

  • volatile实现原理

观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令

  • volatile的使用场景

使用volatile关键字必须具备2个条件:
1、对变量的写操作不依赖于当前值
2、该变量没有包含在具有其他变量的不变式中

你可能感兴趣的:(【Java并发编程】01-内存模型与Synchronized、volatile关键字)