java

导航

Java并发之内存模型

来源 :中华考试网 2020-10-27

  一 并发问题及含义

  并发编程存在原子性、可见性、有序性问题。

  原子性即一系列操作要么都执行,要么都不执行。

  可见性,一个线程对共享变量的修改,另一个线程可能不会马上看到。由于多核CPU,每个CPU核都有高速缓存,会缓存共享变量,某个线程对共享变量的修改会改变高速缓存中的值,但却不会马上写入内存。另一个线程读到的是另一个核缓存的共享变量的值,出现缓存不一致问题。

  有序性,即程序执行的顺序按照代码的先后顺序执行。编译器和处理器会对指令进行重排,以优化指令执行性能,重排不会改变单线程执行结果,但在多线程中可能会引起各种各样的问题。

  二 内存模型

  为了保证共享内存的正确性(可见性、有序性、原子性),内存模型定义了共享内存系统中多线程程序读写操作行为的规范。内存模型解决并发问题

  主要采用两种方式:限制处理器优化和使用内存屏障。

  顺序一致性内存模型是一种理论参考模型,提供了极强的内存可见性保证,具有两大特性:

  一个线程的所有操作按照程序的顺序执行,而不能重排序。

  所有线程只能看到单一的执行顺序。每个操作都必须原子执行且立刻对其它线程可见。

  顺序一致性内存模型禁止很多处理器和编译器重排,影响执行性能,处理器内存模型和JMM对顺序一致性内存模型进行放松,执行性能:处理器内存模型>JMM>顺序一致性内存模型,易编程性:处理器内存模型

  三 java内存模型

  Java内存模型(Java Memory Model ,JMM)是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。

  Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。主内存和工作内存可类比成计算机内存模型中的主存和缓存的概念。

  3.1 java内存模型解决并发问题方法

  原子性,在java中,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。在32位平台下,对64位数据的赋值是需要通过两个操作来完成,不能保证其原子性。要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock保证任一时刻只有一个线程执行该代码块,从而保证了原子性。

  可见性,Java提供了volatile关键字来保证可见性,当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

  JMM通过happens-before关系向程序员提供跨线程的内存可见性保证:

  程序次序规则:一段代码在单线程中执行的结果是有序的。注意是执行结果,因为虚拟机、处理器会对指令进行重排序(重排序后面会详细介绍)。虽然重排序了,但是并不会影响程序的执行结果,所以程序最终执行的结果与顺序执行的结果是一致的。故而这个规则只对单线程有效,在多线程环境下无法保证正确性。

  锁定规则:这个规则比较好理解,无论是在单线程环境还是多线程环境,一个锁处于被锁定状态,那么必须先执行unlock操作后面才能进行lock操作。

  volatile变量规则:这是一条比较重要的规则,它标志着volatile保证了线程可见性。通俗点讲就是如果一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作一定是happens-before读操作的。

  传递规则:提现了happens-before原则具有传递性,即A happens-before B , B happens-before C,那么A happens-before C

  线程启动规则:假定线程A在执行过程中,通过执行ThreadB.start()来启动线程B,那么线程A对共享变量的修改在接下来线程B开始执行后确保对线程B可见。

  线程终结规则:假定线程A在执行的过程中,通过制定ThreadB.join()等待线程B终止,那么线程B在终止之前对共享变量的修改在线程A等待返回后可见。

  有序性,可以使用synchronized和volatile来保证多线程之间操作的有序性。实现方式有所区别:volatile关键字会禁止指令重排。synchronized关键字保证同一时刻只允许一条线程操作。

  3.2 java并发原语

  Java内存模型,除了定义了一套规范,还提供了一系列原语,封装了底层实现后,供开发者直接使用。

  3.2.1 volatile

  内存语义:

  当写一个volatile变量时,JMM会把该线程对应的本地内存中的所有共享变量刷新到主内存。

  当读一个volatile变量,JMM会把该线程对应的本地内存置为无效,线程接下来从主内存中读取共享变量。

  实现:

  编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

  在每个volatile写操作前面插入一个StoreStore屏障。StoreStore屏障禁止上面的普通写和volatile写重排序,保障上面的普通写在volatile写之前刷新到主内存。

  在每个volatile写操作后面插入一个StoreLoad屏障。避免volatile写与后面可能有的volatile读/写重排序。

  在每个volatile读操作的后面插入一个LoadLoad屏障。禁止下面的普通读操作和上面的volatile读操作重排序

  在每个volatile读操作的后面插入一个LoadStore屏障。禁止下面的普通写操作和上面的volatile读操作重排序

  3.2.2 synchronized

  内存语义:

  当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中.

  当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量.

  实现:

  java对象头组成:

  Mark Word

  指向类的指针

  数组长度(只有数组对象才有)

  synchronized用的锁是存在Java对象头里,任何java对象都存在一个锁,JVM基于进入和退出Monitor对象来实现方法同步和代码块同步。代码块同步是使用monitorenter和monitorexit指令实现的,monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处。

  监视器锁(Monitor)本质依赖操作系统的Mutex Lock(互斥锁)来实现,如果互斥量已经上锁,调用线程会阻塞,阻塞或唤醒一条线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态中,因此状态转换需要耗费很多的处理器时间。在jdk1.6中加入对锁的优化措施,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。锁可以升级但不能降级。

  偏向锁:

  当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗必须小于节省下来的CAS原子指令的性能消耗)。

  轻量级锁:

  轻量级锁是为了在线程近乎交替执行同步块时提高性能。多个线程竞争锁,若当前只有一个等待线程,则可通过自旋稍微等待一下,可能另一个线程很快就会释放锁。 但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁。

  重量级锁:

  重量级锁是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高。

  其它锁优化措施:锁消除、锁粗化、自旋锁(忙循环,适用持有锁的线程很快释放锁)、自适应的自旋锁(自旋次数不固定,前一次在同一个锁上的自旋时间及锁的拥有者的状态决定)。

  3.2.3 final

  写final域禁止把final域的写重排序到构造函数之外。对于引用类型:在构造函数内对final域引用对象的成员域的写入,与在构造函数外将这个被构造对象的引用赋值给引用变量,这两个操作不能重排序。防止对象构造完成,未被初始化的final域被访问(要达到此目的,还需确保被构造对象不能在构造函数中“逸出”)

  读final域禁止初次读一个对象的引用和随后初次读这个对象包含的final域之间的重排序。确保在读一个对象的final域前,一定会先读包含这个final域对象的引用,如果引用不为空,引用对象的final域已经被初始化过。

分享到

相关资讯