JMM相关知识点 vovatile synchronized关键字

194 阅读14分钟

java内存模型(JMM)

JMM和底层实现原理

jmm的设计是否是参照计算机的

没错, 计算机分为主内存和每个处理器独有的高速缓存, 它们通过缓存一致性协议来保证各个高速缓存写入和读取主内存时的正确性.

jmm分的主内存和每个线程自有的工作内存就是参照计算机的主内存和工作缓存的. 因此同样需要类似缓存一致性协议的东西, 来保证内存读写的正确性.

jmm模型有啥问题

和计算机拥有的问题一样, jmm模型这么设计主要有下面这个问题

可见性问题

各个工作内存之间的通信是通过主内存完成的:

  • (1) 如果工作内存修改了某个变量, 在写回主内存之前, 其它线程是不可见的
  • (2) 就算写回了主内存, 其它线程如果不主动去主内存获取的话, 其它线程还是不知道变量发生了变化

也就是如果能够做到每个工作内存对于变量的修改能够让所有工作空间都得知, 那么就不存在问题了

线程何时将工作内存中的值写回主内存?

首先如果我们没有用额外的方式来控制(例如volatile关键字), 那么这个时刻是不确定的, 是由cpu底层随机控制的, 也就是说你可能刚修改完, 变化就写回主内存了, 也可能好一回都不写回主内存, 因此我们为了杜绝这种情况, 就需要使得他可控

JMM模型和JVM模型是什么关系?

JVM模型是java虚拟机的运行时的真正模型, JMM只是为了帮助你理解java多线程之间如何互相影响的一个概念性模型, 实际并不存在这个模型.

JMM的主内存的内容, 就对应于jvm堆里面的实例啊, 常量啊, 静态变量等, 而工作内存就是java虚拟机栈里的内容, 例如局部变量啊, 对象的引用啊等.

因此JMM就是一个概念上的模型, 实际的内存使用还是在堆上和栈上.

指令重排序

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。

单线程重排序会带来问题吗?

如果代码执行顺序和我们编写的不一致, 那毫无疑问代码的执行结果是不对的, 但是实际过程中, 编译器和处理器对于代码的重排序不会改变结果(就说明他在重排序的时候非常智能, 知道哪些能重排, 哪些不能)

多线程重排序会带来问题吗? (需要在解决可见性问题上讨论)

单线程重排序问题可以由编译器和处理器保证, 但是多线程之间它们是不做保证的. 因此重排序也会带来执行结果错误的问题.

例如线程A:

int a = 1;  // 1
boolean flag = true;  // 2

线程B:

if(flag) { // 3
	int i = a * a;  // 4
}

假设没有重排序, 在2操作之前, a一定有值了, 但是对于线程A来说, 1和2操作是可以互换的, 因为对于线程A来说, 这两个操作互不影响. 但是对于线程B, 如果1操作先执行, 那么4操作的结果就是对的, 如果2先执行, 那么4操作的结果都是错的.

这里假设线程A对于变量的修改是立马写回主内存, 并且对线程B可见的(这个假设很重要)

JMM模型需要解决哪些问题?

  • (1) 线程之间可见性的问题
  • (2) 不同线程之间指令重排序带来的问题

因此要解决, 这两个问题要同时解决

如何解决JMM模型的问题

如果我们需要用多线程, 并且某个线程对于主内存的变量有修改, 那么我们就需要控制可见性问题和指令重排序的问题

volatile关键字

用volatile关键字修饰变量A

如何解决可见性问题

  • (1) 线程在读取变量A的时候, 不读取自己工作内存中的值, 而是强制去主内存中读值.
  • (2) 线程在对变量A修改的时候, 强制要求线程将A得值刷新回主内存.

如何禁止重排序

通过插入内存屏障来解决重排序带来的问题 (内存屏障也是保证可见性的底层原理)

  • Load屏障: 在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制从新从主内存加载数据;
  • Store屏障: 在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。

(1) 在写A变量之前, 插入StoreStore屏障, 该屏障保证屏障之前的所有写操作一定比屏障之后的所有写操作先执行.

这样就保证了A变量的写入是没有错误的(例如写入依赖别的变量的值) (2) 在写A变量之前, 插入StoreLoad屏障, 该屏障保证屏障之前的所有写操作, 一定在屏障之后的读操作之前执行.

这样就保证了之后的代码如果要读取A变量, 那么读取到的一定是修改后的值.

(3) 在读A变量之后, 插入LoadLoad屏障, 该屏障保证屏障之前的所有读操作, 一定在屏障之后的读操作之前执行.

这样保证了A变量的读取操作是立即发生的 (这个最难理解, 或者可以理解为只应对于特殊情况)

(4) 在读A变量之后, 插入LoadStore屏障, 该屏障保证屏障之前的所有读操作, 一定在屏障之后的写操作之前执行.

这样如果后续写操作会用到A变量的值的话, 保证用到的一定是从主内存刷新得到的值.

volatile解决不了的情况

如果是类似volatile++的操作

即两个线程分别执行下面三个操作

例如用volatile变量修饰a=1

(1) int b = a;  // 1
(2) b = b + 1;  // 2
(3) a = b; // 3

线程A执行了1操作, 拿到了a=1的值, 切换到线程b执行1操作, 拿到了线程a=1的值, 然后线程A执行2, 3操作, a=2, 然后线程B执行2,3 操作, a=2, 但是此时期望a=3;

使用锁 synchronized关键字

锁的信息存放在对象头的markWord信息

如何保证可见性

锁一定是加在对象头里的, 因此锁针对的一定是对象, 可以是Class对象或者实例对象

  • (1) 加锁的时候, 不会读取工作内存中的当前对象值, 而是强制读取主内存的值.
  • (2) 释放锁的时候, 会把对象的值强制刷新到主内存中.

加锁的位置

  1. 修饰一个代码块,被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来的代码,作用的对象是调用这个代码块的对象;
synchronized(this) { // 其实修饰的还是当前对象
	
}
  1. 修饰一个方法,被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象;
 public synchronized void run() {

 }
  1. 修改一个静态的方法,其作用的范围是整个静态方法,作用的对象是这个类的所有对象;
 public synchronized static void method() {

 }

如何禁止重排序

加了锁的对象, 只能被一个线程持有, 也就变成了单线程重排序的问题, 而单线程重排序又不会带来问题, 因此综上所述, 没问题

monitor机制

说一说管程(Monitor)及其在Java synchronized机制中的体现

这是java锁的目的, 就是保证在同一时刻只能有一线程获取共享的资源. 那么我使用了synchronized关键字, 就代表必须要有一个互斥量, java中就是采用c++实现的叫objectmonitor的对象, 该对象存在堆中.

  • (1) java加锁是针对对象的, 因此是否加锁的标志位是存在对象头中的
  • (2) 当对象加锁了, 对象头mark word里就有个指针是指向该对象的objectmonitor对象,
  • (3) objectmonitor对象里存储有持有该对象的线程id. 以及一个入口集合(针对线程)和一个等待集合(针对线程).

jdk 1.6之前 加锁加的都是重量级锁, 上面的objectmonitor对象也是针对重量级锁讨论的, 其它状态的锁的控制方式和objectmonitor不同.

monitor的相关方法

Object基类的wait()方法, notify()方法, notifyAll()方法, 其实都是objectmonitor对象实现的.

objectmonitor有两个队列 入口集合和等待集合:

入口集合:

多个线程尝试去获得objectmonitor对象, 成功的一个持有该对象, objectmonitor对象的线程id就改成这个线程的. 其它的线程就在入口集合中等待. 当锁被释放, 等待集合中的某些线程就会被唤醒(注意是某些, 根据某些策略有所不同), 就会和其它不在等待集合中, 但是刚好也要竞争该锁的线程一起, 尝试去获取objectmonitor对象.

锁的释放时机:(很重要, 也就是等待集合为什么存在的原因)

  • (1) 自然释放:
  • 锁虽然针对的是对象, 但是实际都是对应某些代码的.
  • 自然释放是代码执行结束, 代码返回, 或者代码出现异常, 那么当前线程就会自动释放锁.
  • (2) 主动释放:
  • 当前线程在持有锁的期间, 主动调用该对象的wait()方法.

等待集合:

如果线程是主动释放锁的(主动释放锁肯定是有业务含义的, 不然显得没事不会主动释放的), 调用对象的.wait()方法, 实际就是objectmonitor.wait()方法, 该线程就被放入等待集合.

如果一直没有线程调用这个objectmonitor.notify()方法, 那么等待集合里的线程会一直等待. 如果有线程(注意这个线程可以是任何一个线程, 持不持有锁都可以)调用objectmonitor.notify()方法, 那么等待集合里的某个线程(如果是notifyAll()的话, 所有等待集合里的线程)会被加入到入口集合中, 重新竞争锁.

等待集合里的线程竞争失败的话, 依旧会回到等待集合, 而不是在入口集合.

因此wait()方法和notify()一定是搭配使用, 并且在不同的线程中, 这两个方法的目的就是为了控制线程的执行顺序, 或者做某种变量的传递.

jvm如何知道哪些代码是被加了锁的, 需要去获取锁(指重量级锁)

使用monitorenter和monitorexit指令实现的:

monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处

每个monitorenter必须有对应的monitorexit与之配对

因此当代码执行到 monitorenter指令的时候, 会尝试获取对象对应的monitor, 如果获取到了, 当前线程就可以继续执行代码.

java锁的四种状态

jdk 1.6之后, 为了对synchronized关键字加锁的优化, 引入了四种状态的锁,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。

也就是某个对象使用了synchronized关键字, 但是实际上一开始是偏向锁, 而是当jvm发现 有多个线程来竞争该对象的时候, 才会逐渐把锁升级(升级的程度根据竞争状态的不同), 对象的锁等级一旦被上升, 就算是竞争减少, 也是不会降低等级的.

线程一旦获取锁, 会在当前线程的栈上创建一个lock record队列. 用于记录threadId以及重入的次数

(1) 无锁: 即对象不使用synchronized关键字

(2) 偏向锁:锁状态01

偏向锁针对某一个时间段内针对锁的竞争为0的情况, 也就不会有线程实际竞争锁. 感觉就是和没有锁是一样的.

使用偏向锁的时候, 对象头中不记录objectmonitor的指针, 只记录一个线程ID, 同时某个线程的lock record也记录线程id. 代表当前对象的偏向锁某一时间段内一直属于某个线程.

偏向锁不会被线程主动释放(方便重入), 即对象头里偏向锁指向的存储的线程id, 不会随着那个线程执行完同步代码块后而删除.

线程尝试获取该对象时, 会比较对象头中的线程id和lock record中的线程id, 如果匹配, 表示是重入, 如果不匹配, 判断持有偏向锁的线程是否存活, 如果不存活, 将偏向锁重新指向新的线程id, 如果存活, 判断线程是否在执行相应的同步代码块, 如果没有在执行, 将偏向锁重新指向新的线程id, 否则代码两个线程在同时竞争这个对象了, 偏向锁升级.

(3) 轻量级锁:锁状态00

一定是两个线程在同时竞争对象的时候, 才会变成轻量级锁. 在竞争比较轻的情况下, 轻量级锁的目的是避免使用monitro对象, 因为使用monitro对导致竞争失败的锁陷入阻塞状态, 而轻量级锁让失败的线程通过自旋继续尝试. 自旋一定次数后, 会升级成重量级锁.

线程会将对象头中的markWord信息复制到自己的lock record中, 然后利用CAS的操作, 将对象头里的markWord信息变成一个指针指向某个线程lock record的记录. 此时CAS操作就保证了多个线程只有一个线程能够改变对象头中的markWord信息为指针(线程尝试去修改的时候 会比较对象头里的markword信息和自己lock record的marword信息是不是一致, 但是当一个线程成功修改后, 对象头里的markword就变成一个指针了, 其它线程去比较的时候, 当然就会比较失败, CAS操作就会失败). CAS操作成功的线程获得锁, 修改锁的状态从01变成00. 当线程执行完代码块后,

竞争失败的锁会自旋, 旋转一定次数后, 轻量级锁升级成重量级锁. 此时对象头中markword已经不再是指向持有轻量级锁的线程lock record的指针了, 而是变成了指向monitor对象的指针.

轻量级锁的释放:

持有清亮级锁的线程在自己同步代码块执行完之后, 才会去释放, 释放的时候, 会尝试将栈中lock record的信息复制回头像头的markword, 如果对象头中的markword依旧是指向当前栈的指针 ,是可以成功替换的, 如果对象头的markwod已经变成monitor对象的指针, 那么就直接释放锁, 不用将自己的markword信息复制回去了

轻量级锁继续使用的条件:

持有轻量级锁的线程在释放锁的时候, 能够成功复制回对象头的markword, 那么下次竞争依旧是轻量级锁.

(4) 重量级锁:monitor机制实现 锁状态10