美文网首页
33.Android架构-线程(四)-JMM

33.Android架构-线程(四)-JMM

作者: 任振铭 | 来源:发表于2020-10-10 09:19 被阅读0次

JMM基础-计算机原理

Java内存模型即Java Memory Model,简称JMM。JMM定义了Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式,JVM是整个计算机虚拟模型,所以JMM是隶属于JVM的。Java1.5版本对其进行了重构,现在的Java仍沿用了Java1.5的版本。JMM遇到的问题与现代计算机中遇到的问题是差不多的。

物理计算机中的并发问题

物理机遇到的并发问题与虚拟机中的情况有不少相似之处,物理机对并发的处理方案对于虚拟机的实现也有相当大的参考意义。
根据《Jeff Dean在Google全体工程大会的报告》我们可以看到


图片1.png

计算机在做一些我们平时的基本操作时,需要的响应时间是不一样的。
(以下案例仅做说明,并不代表真实情况。)
如果从内存中读取1M的int型数据由CPU进行累加,耗时要多久?
做个简单的计算,1M的数据,Java里int型为32位,4个字节,共有
1024*1024/4 = 262144个整数 ,则CPU 计算耗时:262144 0.6 = 157 286 纳秒,而我们知道从内存读取1M数据需要250000纳秒,两者虽然有差距(当然这个差距并不小,十万纳秒的时间足够CPU执行将近二十万条指令了),但是还在一个数量级上。但是,没有任何缓存机制的情况下,意味着每个数都需要从内存中读取,这样加上CPU读取一次内存需要100纳秒,262144个整数从内存读取到CPU加上计算时间一共需要262144100+250000 = 26 464 400 纳秒,这就存在着数量级上的差异了。
而且现实情况中绝大多数的运算任务都不可能只靠处理器“计算”就能完成,处理器至少要与内存交互,如读取运算数据、存储运算结果等,这个I/O操作是基本上是无法消除的(无法仅靠寄存器来完成所有运算任务)。早期计算机中cpu和内存的速度是差不多的,但在现代计算机中,cpu的指令速度远超内存的存取速度,由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。

图片2.png 图片3.png

在计算机系统中,寄存器划是L0级缓存,接着依次是L1,L2,L3(接下来是内存,本地磁盘,远程存储)。越往上的缓存存储空间越小,速度越快,成本也更高;越往下的存储空间越大,速度更慢,成本也更低。从上至下,每一层都可以看做是更下一层的缓存,即:L0寄存器是L1一级缓存的缓存,L1是L2的缓存,依次类推;每一层的数据都是来至它的下一层,所以每一层的数据是下一层的数据的子集。

图片4.png

在现代CPU上,一般来说L0, L1,L2,L3都集成在CPU内部,而L1还分为一级数据缓存(Data Cache,D-Cache,L1d)和一级指令缓存(Instruction Cache,I-Cache,L1i),分别用于存放数据和执行数据的指令解码。每个核心拥有独立的运算处理单元、控制器、寄存器、L1、L2缓存,然后一个CPU的多个核心共享最后一层CPU缓存L3

Java内存模型(JMM)

从抽象角度看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在,他涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器的优化


图片1.png 图片2.png
可见性

可见性指的是当多个线程操作同一个变量时,一个线程修改了这个变量的值,其他线程能够立即读取到修改后的值。
由于线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存的变量,那么对于共享变量A,他们首先是在自己的工作内存中被修改,然后才会同步到主内存,但是并不会及时的刷新到主内存,而是会有一定的时间差,如果在刷新到主内存之前有其他线程读取了这个值,那么就会导致读取到的不是最新的值,这就是可见性的问题
要解决共享变量可见性的问题,1 volatile 2 加锁

原子性

原子性就是一个操作或者多个操作,一旦开始执行,在执行完成之前不会被其他操作打断,那么这个操作或者这多个操作就是具有原子性的

导致原子性问题的根源在于cpu时间片的切换,cpu时间的分配都是以线程为单位的,并且是分时调用,操作系统允许某个线程执行一小段时间,例如50毫秒,过了50毫秒操作系统就会重新选择一个线程来执行,这个50毫秒就是“时间片”

那么时间片的切换会带来什么问题?我们知道cpu的时间片切换是以cpu指令为单位进行的,而cpu指令不等于我们写的一行代码,因为我们的一行代码可能是一条cpu执行,也可能需要多条cpu指令才能执行完,比如a++,我们说自增运算不具有原子性,所以他是由多条指令组成的,所以这一行代码在执行过程中就有可能被时间片切换打断,导致意外的问题。

volatile

可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。

当一个变量被volatile修饰的时候,一旦某一个线程对这个变量做了修改,那么修改后的值会被立即同步到主存中,并且会使其他线程的工作内存中的记录的这个变量的副本失效,此时当其他线程再次读取这个值时,会强制从主存中读取

原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。

volatile虽然能保证执行完及时把变量刷到主内存中,但对于count++这种非原子性、多指令的情况,由于线程切换,线程A刚把count=0加载到工作内存,线程B就可以开始工作了,这样就会导致线程A和B执行完的结果都是1,都写到主内存中,主内存的值还是1不是2

volatile的实现原理

volatile关键字修饰的变量会存在一个“lock:”前缀,lock不是一种内存屏障,但是它能完成类似内存屏障的功能,lock会对cpu总线和高速缓存加锁,可以理解为cpu指令级的一种锁,同时该指令会将当前处理器缓存行的数据直接写入到系统内存中,且这个写回内存的操作会使在其他cpu里缓存了该地址的数据失效

synchronized的实现原理

Synchronized在JVM里的实现都是基于进入和退出Monitor对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的MonitorEnter和MonitorExit指令来实现。
对同步块,MonitorEnter指令插入在同步代码块的开始位置,当代码执行到该指令时,将会尝试获取该对象Monitor的所有权,即尝试获得该对象的锁,而monitorExit指令则插入在方法结束处和异常处,JVM保证每个MonitorEnter必须有对应的MonitorExit。
对同步方法,从同步方法反编译的结果来看,方法的同步并没有通过指令monitorenter和monitorexit来实现,相对于普通方法,其常量池中多了ACC_SYNCHRONIZED标示符。
JVM就是根据该标示符来实现方法的同步的:当方法被调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。
synchronized使用的锁是存放在Java对象头里面,


图片3.png

具体位置是对象头里面的MarkWord,MarkWord里默认数据是存储对象的HashCode等信息,


图片4.png

但是会随着对象的运行改变而发生变化,不同的锁状态对应着不同的记录存储方式


图片1.png
同步方法中有synchronized和无synchronized的对比(javap -v TestSync.class反编译class文件)
    public synchronized void test() {
        count++;
    }
    public void test() {
        count++;
    }

带synchronized

public synchronized void test();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field count:I
         3: iconst_1
         4: iadd
         5: putstatic     #2                  // Field count:I
         8: return

不带synchronized

public void test();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field count:I
         3: iconst_1
         4: iadd
         5: putstatic     #2                  // Field count:I
         8: return

可以看到,二者的区别就是flags中前者多了一个ACC_SYNCHRONIZED标记

同步代码块有synchronized和无synchronized的对比(javap -v TestSync.class反编译class文件)
    public void test0() {
        synchronized (this){
            count++;
        }
    }
    public void test0() {
            count++;
    }

带synchronized

public void test0();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter
         4: getstatic     #2                  // Field count:I
         7: iconst_1
         8: iadd
         9: putstatic     #2                  // Field count:I
        12: aload_1
        13: monitorexit
        14: goto          22
        17: astore_2
        18: aload_1
        19: monitorexit
        20: aload_2
        21: athrow
        22: return

不带synchronized

 public void test0();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field count:I
         3: iconst_1
         4: iadd
         5: putstatic     #2                  // Field count:I
         8: return

可以看到二者的区别在于在被synchronized锁住的代码中,有3: monitorenter13: monitorexit两个指令

JVM对synchronized进行的优化

早期Java版本中(JDK1.6前),synchronized属于重量级锁,效率低,monitor依赖于低层的操作系统的Mutex Lock来实现。而操作系统实现线程中的切换时,涉及到用户态到内核态的切换,这是一个非常重的操作,时间成本较高。这是早期 synchronized 效率低下的原因。JDK1.6后,JVM官方对锁做了较大优化

1.偏向锁
2.轻量级锁
3.锁粗化
4.锁消除
5.适应性自旋
锁的状态

一共有四种状态,无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态,它会随着竞争情况逐渐升级。锁可以升级但不能降级,目的是为了提高获得锁和释放锁的效率。

Monitor
上边也提到了,Synchronized在JVM里的实现都是基于进入和退出
Monitor对象来实现方法同步和代码块同步,那么Monitor的实现是什
么原理呢?

简单来说 monito存储了所有在等待获取该monitor的线程对象。(线
程生命周期中存在两种状态,运行态和阻塞态),当A线程获取到
monitor对象,B线程在尝试获取的时候会失败并进入阻塞态,直到A
线程执行完成释放monitor对象,并唤醒B线程,B被唤醒后,获取到
了A释放了的monitor对象,继续运行,直到完成。(`A怎么知道有B
在等待?看第一句话,monitor对象中存储了所有在等待获取该
monitor的线程对象`)
偏向锁(为了减少不必要的CAS)

大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁,减少不必要的CAS操作。
偏向锁,顾名思义,它会偏向于第一个访问锁的线程,如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的,减少加锁/解锁的一些CAS操作(比如等待队列的一些CAS操作),这种情况下,就会给线程加一个偏向锁。 如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁恢复到标准的轻量级锁。它通过消除资源无竞争情况下的同步原语,进一步提高了程序的运行性能。

偏向锁获取过程:
步骤1、 访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01,确认为可偏向状态。
步骤2、 如果为可偏向状态,则测试线程ID是否指向当前线程,如果是,进入步骤5,否则进入步骤3。
步骤3、 如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行5;如果竞争失败,执行4。
步骤4、 如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。(撤销偏向锁的时候会导致stop the word)
步骤5、 执行同步代码。
偏向锁的释放:
偏向锁的撤销在上述第四步骤中有提到。偏向锁只有遇到其他线程尝
试竞争偏向锁时,持有偏向锁的线程才会释放偏向锁,线程不会主动
去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点
上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁
对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为
“01”)或轻量级锁(标志位为“00”)的状态。

偏向锁的适用场景
始终只有一个线程在执行同步块,在它没有执行完释放锁之前,没有
其它线程去执行同步块,在锁无竞争的情况下使用,一旦有了竞争就
升级为轻量级锁,升级为轻量级锁的时候需要撤销偏向锁,撤销偏向
锁的时候会导致stop the word操作; 

在有锁的竞争时,偏向锁会多做很多额外操作,尤其是撤销偏向所的
时候会导致进入安全点,安全点会导致stw,导致性能下降,这种情况
下应当禁用。
jvm开启/关闭偏向锁
开启偏向锁:-XX:+UseBiasedLocking 
-XX:BiasedLockingStartupDelay=0
关闭偏向锁:-XX:-UseBiasedLocking
轻量级锁(通过CAS操作,在竞争并不激烈的情况下,降低线程挂起和恢复的时间消耗)

轻量级锁是由偏向锁升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁

获取轻量锁:

1.判断当前对象是否处于无锁状态(偏向锁标记=0,无锁状态=01)
如果是,则JVM会首先将当前线程的栈帧中建立一个名为锁记录
(Lock Record)的空间,用于存储当前对象的Mark Word拷贝。(官
方称为Displaced Mark Word)。接下来执行第2步。如果对象处于有
锁状态,则执行第3步
2.JVM利用CAS操作,尝试将对象的Mark Word更新为指向Lock 
Record的指针。如果成功,则表示竞争到锁。将锁标志位变为00(表
示此对象处于轻量级锁的状态),执行同步代码块。如果CAS操作失
败,则执行第3步。
3.判断当前对象的Mark Word 是否指向当前线程的栈帧,如果是,则
表示当前线程已经持有当前对象的锁,直接执行同步代码块。否则,
说明该锁对象已经被其他对象抢占,此后为了不让线程阻塞,还会进
入一个自旋锁的状态,如在一定的自旋周期内尝试重新获取锁,如果
自旋失败,则轻量锁需要膨胀为重量锁(重点),锁标志位变为10,
后面等待的线程将会进入阻塞状态。
释放轻量锁:
轻量级锁的释放操作,也是通过CAS操作来执行的,步骤如下:
1.取出在获取轻量级锁时,存储在栈帧中的 Displaced Mard Word 数
据。
2.用CAS操作,将取出的数据替换到对象的Mark Word中,如果成
功,则说明释放锁成功,如果失败,则执行第3步。
3.如果CAS操作失败,说明有其他线程在尝试获取该锁,则要在释放
锁的同时唤醒被挂起的线程。

重量级锁

重量级锁通过对象内部的监视器(Monitor)来实现,而其中monitor本质上是依赖于低层操作系统的 Mutex Lock实现。
操作系统实现线程切换,需要从用户态切换到内核态,切换成本非常高。

适应性自旋

在轻量级锁获取失败时,为了避免线程真实的在系统层面被挂起,还会进行一项称为自旋锁的优化手段。

这是基于以下假设:
大多数情况下,线程持有锁的时间不会太长,将线程挂起在系统层面耗费的成本较高。
而“适应性”则表示,该自学的周期更加聪明。自旋的周期是不固定的,它是由上一次在同一个锁上的自旋时间 以及 锁拥有者的状态 共同决定。

具体方式是:如果自旋成功了,那么下次的自旋最大次数会更多,因为JVM认为既然上次成功了,那么这一次也有很大概率会成功,那么允许等待的最大自旋时间也相应增加。反之,如果对于某一个锁,很少有自旋成功的,那么就会相应的减少下次自旋时间,或者干脆放弃自旋,直接升级为重量锁,以免浪费系统资源。

有了适应性自旋,随着程序的运行信息不断完善,JVM会对锁的状态预测更加精准,虚拟机会变得越来越聪明。

锁粗化

我们知道,在使用锁的时候,需要让同步的作用范围尽可能的小——仅在共享数据的操作中才进行。这样做的目的,是为了让同步操作的数量尽可能小,如果村子锁竞争,那么也能尽快的拿到锁。
在大多数的情况下,上面的原则是正确的。
但是如果存在一系列连续的 lock unlock 操作,也会导致性能的不必要消耗.
粗化锁就是将连续的同步操作连在一起,粗化为一个范围更大的锁。
例如,对Vector的循环add操作,每次add都需要加锁,那么JVM会检测到这一系列操作,然后将锁移到循环外。

锁消除

锁消除是JVM进行的另外一项锁优化,该优化更彻底。
JVM在进行JIT编译时,通过对上下文的扫描,JVM检测到不可能存在共享数据的竞争,如果这些资源有锁,那么会消除这些资源的锁。这样可以节省毫无意义的锁请求时间。
虽然大部分程序员可以判断哪些操作是单线程的不必要加锁,但我们在使用Java的内置 API时,部分操作会隐性的包含锁操作。例如StringBuffer的操作,HashTable的操作。
锁消除的依据,是逃逸分析的数据支持。

相关文章

  • 33.Android架构-线程(四)-JMM

    JMM基础-计算机原理 Java内存模型即Java Memory Model,简称JMM。JMM定义了Java 虚...

  • java多线程(六)---Java内存模型

    1 Java内存模型(JMM) Java线程间的通信由Java内存模型(JMM)控制,JMM决定一个线程对共享变量...

  • 初识Java内存模型

    Java内存模型(JMM) JMM规定Java每个线程都有自己的工作内存(Working Memory),线程的工...

  • 从JMM引申到线程安全

    1、世界存在线程安全问题2、提出JMM规范3、JVM实现参考JMM,解决线程安全问题 JMM定义屏蔽掉各种硬件和操...

  • Java-可见性、原子性、有序性

    关键字:Java内存模型(JMM)、线程安全、可见性、原子性、有序性 1.线程安全(JMM) 多线程执行某个操作的...

  • java内存模型(JMM)解刨

    JMM简介 Java的内存模型JMM(Java MemoryModel)JMM主要是为了规定了线程和内存之间的一些...

  • JMM基础

    Java线程之间的通信由Java内存模型(JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可...

  • Java内存模型学习笔记

    什么是JMM JMM(java内存模型)源于物理机器CPU架构的内存模型,最初用于解决MP(多处理器架构)系统中的...

  • JVM(七)JMM内存模型

    1.JMM产生背景和定义 JMM(Java内存模型)源于物理机CPU架构的内存模型,最初用于解决MP(多处理器架构...

  • Java并发总结

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

网友评论

      本文标题:33.Android架构-线程(四)-JMM

      本文链接:https://www.haomeiwen.com/subject/jiimyktx.html