Java并发编程(3)


Java内存模型

1、说一下你对Java内存模型(JMM)的理解

   Java程序运行在各种硬件和操作系统上,不同硬件的CPU缓存策略、内存访问顺序、指令重排规则可能都不一样。那JMM是Java规范定义的一个抽象模型,是一套规则:

  • 线程和主内存的交互:线程如何从主内存读变量、写变量
  • 可见性保证:什么时候一个线程对变量的修改能被另一个线程看到
  • 有序性保证:哪些操作在多线程下不能随意重排,哪些可以。
//例如
volatile int flag = 0
//在x86CPU上可能会翻译成某种内存屏障指令
//在ARM CPU上可能是另一种
//但Java程序员只需知道:volatile保证可见性和禁止指令重排,效果是一样的

  JMM定义了线程和主内存之间的抽象关系:线程之间共享变量存储在主内存,每个线程有一个私有的本地内存。

  如果是双核CPU架构:

  • 每个核心:控制器+运算器+私有的一级缓存(L1缓存)
  • 共享缓存:有个架构有L2或L3,多个核心共享
  • 主内存:所有CPU都能访问

  JMM内存模型里定义了两个层次:

  • 主内存:所有线程共享,对应硬件上的主内存(DRAM)
  • 工作内存:每个线程独有,用来保存主内存中变量的副本
  • 流程:
    • 变量先从住内存加载到工作内存(寄存器/缓存)
    • 线程所有操作只在工作内存完成
    • 结果再写回主内存
    • 线程之间想看到对方的修改,必须通过主内存完成数据交换

 

2、说说你对原子性、可见性、有序性的理解

  •  原子性:一个操作不可再分,要么全部完成,要么全部不做
    • 在Java中,基本的读取和写入(如int x = 1)是原子的。但复合操作不是原子的(如i++)
    • 保证方式:synchronized或ReetrantLock(锁住临界区)、AtomicInteger、AtomicLong等原子类(通过CAS+volatile)
  • 可见性:一个线程对共享变量的修改,能被其他线程及时看到。(由于CPU缓存和寄存器存在,线程可能看到的是旧值)
    • 保证方式:volatile(保证写入立刻刷新到主内存);synchronized/Lock(解锁时强制刷新到主内存,加锁时清空工作内存,重新读)
  • 有序性:程序执行顺序和代码顺序一致,但编译器和CPU为了优化,可能会指令重排。(单线程不影响,多线程可能影响)
    • 保证方式:volatile禁止指令重排;synchronized/Lock进入临界区和退出时,JMM会插入内存屏障,保证临界区内操作的顺序性。JMM的happens-before原则,定义哪些操作必须对另一个操作可见,从而间接约束了顺序。

3、说说什么是指令重排

   指令重排 = 编译器或CPU在执行时,为了优化性能,会调整代码语句的执行顺序。(有序性)

  三种指令重排类型:

  • (1)编译器优化的重排
    • Java源代码–>字节码–>机器指令,中间编译器可能优化。只要不改变单线程的最终结果,就可以调整语句顺序。
  • (2)指令级并行(ILP)重排

    • CPU支持流水行并行,若指令间没有数据依赖,CPU会乱序执行以提高效率
  • (3)内存系统的重排
    • 因为有CPU cache,写缓冲区,导致内存的读写顺序看起来是乱的。
    • 假如线程A对变量x写入后,先放在写缓冲区,没立刻刷新到主内存。线程B去读时,可能还是旧值。
instance = new Singleton();

  三个底层步骤(理想顺序):

  • 分配内存:给Singleton对象分配一块内存控件,假设内存地址时0x1234。
  • 调用构造方法:在0x1234这块内存上,执行构造函数,把对象真正初始化好(比如成员变量赋值)
  • 把引用赋给变量instance:instance指向0x1234,之后通过instance就能找到这个对象。

  指令重排(为了优化性能,步骤2和3可能被交换)。若第三步变成第二步,此时对象还没初始化完。

  如果是多线程:A先执行new Singleton(),到第二步引用赋值给instance,此时线程A被切换走了。线程B看到if(instance == null),发现instance不是null,就直接返回instance,但其实这个uidx还没初始化完成。就可能会出现“半初始化对象”被使用的情况。

4、指令重排有限制吗?happens-before了解吗

   是有限制的,需要遵守两个主要约束:as-if-serial(后面讲)happens-before规则。

  happens-before规则是JMM提供的多线程间的有序性保证,定义了哪些操作对其他线程可见、必须按顺序。定义:如果操作A  happens-before 操作B,那A的结果必须对B可见,且A的执行顺序排在B之前。(注意,这是一种约束关系,不等于物理时间顺序。这只是用来保证逻辑先后关系,用来保证多线程下结果正确,同时允许底层做性能优化)

  六大原则:

  • 程序顺序规则:在一个线程内,按代码顺序,前面的操作happens-before 后面的操作
  • 监视器锁规则:对一个锁的解锁 happens-before 随后对这个锁的解锁。(如线程A释放锁->线程B获取同一把锁–>B必然能看到A的修改)
  • volatile变量规则:对一个volatile变量的写 happens-before 后续对这个变量的读。(如线程A flag = true–>线程B读取flag一定能看到true)
  • 传递性:若A happens-before B,B happens-before  C,那么A happens-before  C。
  • start规则:线程A调用threadB.start(),happens-before 线程B的任意操作。(如A在启动B之前的写操作,B一定都能看到)
  • join()规则:线程A调用threadB.join()并成功返回,意味着线程B的所有操作happens-before A从join返回(如B执行完写操作,A在join后一定能看到结果)

5、as-if-serial是什么?单线程的程序一定是顺序的吗?

   as-if-serial意思是:不管怎么重排,单线程程序的执行结果不能被改变。编译器和处理器不会对存在数据依赖关系的操作做重排,因为这会改变执行结果。但是,若操作之间不存在数据依赖关系,这些操作可能会被编译器和处理器重排。

double p i = 3.14 ; // A
double r = 1.0 ; // B
double area = p i * r * r ; // C
//C依赖A和B,A和B之间没有依赖
//顺序1:A-B-C
//顺序2:B-A-C
//C不可能在A、B前面

6、volatile实现原理

 (1)可见性

  相比synchronized的加锁方式来解决共享变量的内存可见性问题,volatile是更轻量的选择,没有上下文切换的额外开销成本。一个变量被声明为volatile时,线程再写入变量时不会把值缓存在寄存器或其他地方,而是会把值刷新回主内存,当其他线程读取该共享变量,会从主内存获取最新值,而不是使用当前线程的本地内存中的值。

 (2)有序性

  没有内存屏障可能会发生什么?

  • CPU可能把flag=true先执行并刷出,而a=1还在寄存器/缓存里没同步到主内存。
  • 指令乱序,导致“半初始化对象”
  • 读到旧值(缓存不一致),若没有屏障,写操作不会强制刷新到主内存

  volatile怎么保证有序性:JMM在volatile前后都会插入内存屏障,限制重排。

  • 写volatile前:保证之前写的变量先对外可见;保证bolatile写对后续读可见
  • 读volatile时:保证volatile读完后,才能读其他变量;保证volatile读完后,才能写其他变量。

  volatile修饰:实例变量、静态变量。不能修饰局部变量、方法和类(在线程栈中,本来就不共享)

线程安全:保证原子性、可见性、有序性

volatile只能保证后两者。

 

参考

[1] 沉默王二公众号