深入理解JVM

JVM

Java内存管理

1.运行时数据区域划分

JVM内存划分
  1. 堆(Heap)溢出异常

    Java Heap是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此区域的唯一目的就是存放对象实例,从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以Java堆中还可以细分为:新生代和老年代

  2. 栈(Stack)溢出异常

    • JVM方法栈

      每个方法在执行的同时都会创建一个栈帧用于存储局部变量表,操作数栈,动态链接,方法出口等信息。 每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程,当方法被调用则入栈,一旦完成调用则出栈。所有的栈帧都出栈后,线程就结束了。

    • 本地方法栈(非Java代码接口)

      Native Method Stack与虚拟机栈的作用非常相似,区别是:虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法。

  3. 方法区

    Method Area是各个线程共享内存区域,用于存储已被虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码等数据。 在HtoSpot虚拟机中该区域叫永久代 。

    • 运行时常量池

      Runtime Constant Pool是方法区的一部分。Class文件中除了有类的版本,字段,方法,接口等描述信息,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。 另外一个重要特征是具备动态性 ,运行期间也可能将新的常量放入池中,这种特性被开发人员利用比较多的就是String类的intern()方法

  4. 程序计数器(CS:IP)

    程序计数器(Program Counter Register)是一块较小的内存空间,可以看做当前线程所执行的字节码的行号指示器。

垃圾回收期和内存分配策略

垃圾收集(Garbage Collection,GC)需要思考以下三个问题:

  • 哪些内存需要回收
  • 什么时候回收
  • 如何回收

死亡对象确认

引用计数算法

对象添加一个引用计数器,没有一个地方引用它时,计数器就加一;当引用失效,计数器减一;计数器为零的对象即不可能再使被使用的。但是此算法无法解决循环引用问题,JVM并没有采用。

可达性分析算法

此算法的核心思想:通过一系列称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称为“引用链”,当一个对象到 GC Roots 没有任何的引用链相连时(从 GC Roots 到这个对象不可达)时,证明此对象不可用。

可达性分析

对象Object5 —Object7之间虽然彼此还有联系,但是它们到 GC Roots 是不可达的,因此它们会被判定为可回收对象。

Java中的引用概念

如果reference类型中的存储的数值代表的是另一块内存的起始地址,就成为这块内存代表着一个引用。此种定义太狭隘,JDK1.2进行了扩充。

  • 强引用:Object obj = new Object();
  • 软引用:SoftReference,如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。
  • 弱引用:在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。
  • 虚引用: 如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。虚引用主要用来跟踪对象被垃圾回收的活动

垃圾收集算法

标记-清除算法

标记-清除算法分为两个阶段:标记阶段和清除阶段。标记阶段的任务是标记出所有需要被回收的对象,清除阶段就是回收被标记的对象所占用的空间。

img

标记-清除算法实现起来比较容易,但是有一个比较严重的问题就是容易产生内存碎片,碎片太多可能会导致后续过程中需要为大对象分配空间时无法找到足够的空间而提前触发新的一次垃圾收集动作。

复制算法

它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用的内存空间一次清理掉,这样一来就不容易出现内存碎片的问题。具体过程如下图所示:

img

虽然实现简单,运行高效且不容易产生内存碎片,但是却对内存空间的使用做出了高昂的代价,因为能够使用的内存缩减到原来的一半。

标记-整理算法

为了解决Copying算法的缺陷,充分利用内存空间,提出了Mark-Compact算法。该算法标记阶段和Mark-Sweep一样,但是在完成标记之后,它不是直接清理可回收对象,而是将存活对象都向一端移动(美团面试题目,记住是完成标记之后,先不清理,先移动再清理回收对象),然后清理掉端边界以外的内存(美团问过). 标记-整理算法是在标记-清除算法的基础上,又进行了对象的移动,因此成本更高,但是却解决了内存碎片的问题。

分代收集算法

分代收集算法是目前大部分JVM的垃圾收集器采用的算法。它的核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域。 一般情况下将堆区划分为老年代(Tenured Generation)和新生代(Young Generation)。 老年代的特点是每次垃圾收集时只有少量对象需要被回收,而新生代的特点是每次垃圾回收时都有大量的对象需要被回收,那么就可以根据不同代的特点采取最适合的收集算法

大部分垃圾收集器对于新生代都采取Copying算法,因为新生代中每次垃圾回收都要回收大部分对象,也就是说需要复制的操作次数较少,但是实际中并不是按照1:1的比例来划分新生代的空间的,一般来说是将新生代划分为一块较大的Eden空间和两块较小的Survivor空间(一般为8:1:1),每次使用Eden空间和其中的一块Survivor空间,当进行回收时,将Eden和Survivor中还存活的对象复制到另一块Survivor空间中,然后清理掉Eden和刚才使用过的Survivor空间

而由于老年代的特点是每次回收都只回收少量对象,一般使用的是Mark-Compact算法。

年轻代(Young Generation)的回收算法
  1. 所有新生成的对象首先都是放在年轻代的。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。
  2. 新生代内存按照8:1:1的比例分为一个eden区和两个survivor(survivor0,survivor1)区。一个Eden区,两个 Survivor区(一般而言)。大部分对象在Eden区中生成。回收时先将eden区存活对象复制到一个survivor0区,然后清空eden区,当这个survivor0区也存放满了时,则将eden区和survivor0区存活对象复制到另一个survivor1区,然后清空eden和这个survivor0区,此时survivor0区是空的,然后将survivor0区和survivor1区交换,即保持survivor1区为空(美团面试,问的太细,为啥保持survivor1为空,答案:为了让eden和survivor0 交换存活对象), 如此往复。当Eden没有足够空间的时候就会 触发jvm发起一次Minor GC
  3. 当survivor1区不足以存放 eden和survivor0的存活对象时,就将存活对象直接存放到老年代。若是老年代也满了就会触发一次Full GC(Major GC),也就是新生代、老年代都进行回收。
  4. 新生代发生的GC也叫做Minor GC,MinorGC发生频率比较高(不一定等Eden区满了才触发)。
年老代(Old Generation)的回收算法
  1. 在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。
  2. 当老年代内存满时触发Major GC即Full GC,Full GC发生频率比较低,老年代对象存活时间比较长,存活率标记高。
新生代和老年代的区别

所谓的新生代和老年代是针对于分代收集算法来定义的,新生代又分为Eden和Survivor两个区。 数据会首先分配到Eden区 当中(当然也有特殊情况,如果是大对象那么会直接放入到老年代(大对象是指需要大量连续内存空间的java对象)。),当Eden没有足够空间的时候就会 触发jvm发起一次Minor GC。如果对象经过一次Minor GC还存活,并且又能被Survivor空间接受,那么将被移动到Survivor空 间当中。并将其年龄设为1,对象在Survivor每熬过一次Minor GC,年龄就加1,当年龄达到一定的程度(默认为15)时,就会被晋升到老年代 中了,当然晋升老年代的年龄是可以设置的。如果老年代满了就执行:Full GC 因为不经常执行,因此采用了 Mark-Compact算法清理

GC是什么时候触发的

由于对象进行了分代处理,因此垃圾回收区域、时间也不一样。GC有两种类型:Scavenge GC和Full GC

  • Scavenge GC: 一般情况下,当新对象生成,并且在Eden申请空间失败时,就会触发Scavenge GC,对Eden区域进行GC,清除非存活对象,并且把尚且存活的对象移动到Survivor区。然后整理Survivor的两个区。这种方式的GC是对年轻代的Eden区进行,不会影响到年老代。
  • Full GC: 对整个堆进行整理,包括Young、Tenured和Perm。Full GC因为需要对整个堆进行回收,所以比Scavenge GC要慢,因此应该尽可能减少Full GC的次数。在对JVM调优的过程中,很大一部分工作就是对于Full GC的调节。 有如下原因可能导致Full GC :
    1. 年老代(Tenured)被写满;
    2. 持久代(Perm)被写满
    3. System.gc()被显示调用
    4. 上一次GC之后Heap的各域分配策略动态变化

虚拟机类加载机制

虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析以及初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。

img

什么时候需要加载类?

  1. 使用new关键字实例化对象、读取或者设置一个静态字段
  2. 使用java.lang.reflect包的方法对类进行反射调用时,如果类没有进行过初始化,那就需先触发其初始化
  3. 当初始化一个类时,如果发现其父类尚未初始化,则需先触发其父类的初始化
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类
  5. 当使用动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先进行初始化

加载类过程

  1. 加载:“加载”是类加载过程的一个阶段

    通过类的全限名获取此类的二进制字节流;将字节流转换为方法区的运行时数据结构;生成java.lang.class对象,作为方法区这个类的各种数据的访问入口。

  2. 验证

    确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全

  3. 准备

    类变量分配内存,并设置类变量初始值的阶段,这些变量使用的内存,都在方法区中进行分配。

    public static int value = 123  // 初始值是0,而不是123,这个时候没有执行任何Java方法
    

    要注意,一般是数据类型的零值,但是还有特殊情况,比如被final修饰,存在ConstantValue属性,会在准备阶段就会赋值。

  4. 解析

    解析阶段就是将常量池中的符号引用替换成直接引用了。这里需要延伸一下知识,Java为了实现其动态性,在编译的时候都是通过符号引用来占位子,在这个阶段就要对应具体的引用对象了

  5. 初始化

    初始化是类加载的最后一步,会真正执行类中定义的Java代码。准备阶段,变量赋予了零值,初始化阶段要赋予真正的值

类加载器

通过类的全限名获取此类的二进制字节流的实现动作,成为“类加载器”

类与类加载器

类加载器虽然只用于实现类的加载动作,但它在Java中还有更重要的作用。比较两个类是否相等,只有在两个类是由同一个类加载器加载的前提下才有意义。同一个class文件被同一个虚拟机的不用类加载器加载,那这两个类一定不相等。

从代码分包的角度来看,类加载器分为以下三种:

  • 启动类加载器(Bootstrap ClassLoader):这个放在java_home\lib目录中,或者-Xbootclasspath参数所指定的路径,被虚拟机识别(固定名称)。 这个加载器无法被程序直接引用,使用直接返回Null
  • 扩展类加载器(Extension ClassLoader):这个加载器由sun.misc.Launcher$ExtClassLoader实现,负责加载java_home\lib\ext目录中的,或者被java.ext.dirs系统变量指定的路径所在类库,开发者可以直接使用扩展类加载器。
  • 应用程序类加载器(Application ClassLoader):这个类加载器由sun.misc.Launcher$AppClassLoader实现。这个类加载器是ClassLoader中的getSystemClassLoader方法的返回值,所以也称为系统类加载器。 负责加载用户ClassPath上所指定的类库,可以直接使用,如果代码里面没有定义自己的加载器,就会默认使用这个。

Java中的类是存在继承关系的,所有的类都继承自java.lang.Object。如果用户自己再编写一个称为java.lang.Object的类,并放在程序的Classpath中,那系统中会出现多个不同的Object类,Java中的类不再是 instance of Object, java类型体系中最基础的行为也无法保证 。所以需要一个模型来限制类加载关系,如果类已经加载就不会再次加载。

双亲委派模型

如果一个类收到了类加载的请求,它首先不会自己去加载这个类,而是把请求委派给父类加载器去完成,每一个层次的类加载器都是如此。因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成此加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。

img

双亲委派模型要求除了顶层的启动类加载器之外,其他的类加载器都应该有自己的父类加载器。这里类加载器之间的父子关系一般不会以继承(Inheritance)的关系来实现,而是都使用组合(Composition)关系来复用父加载器的代码。

protected Class loadClass(String name, boolean resolve)
        throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        // First, check if the class has already been loaded
        Class c = findLoadedClass(name);
        if (c == null) {
            long t0 = System.nanoTime();
            try {
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // ClassNotFoundException thrown if class not found
                // from the non-null parent class loader
            }
            if (c == null) {
                // If still not found, then invoke findClass in order
                // to find the class.
                long t1 = System.nanoTime();
                c = findClass(name);
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

破坏双亲委派模型

双亲委派模型不是一个强制性的约束模型,为了开发利用Java强大的动态性,一般通过破坏双亲委派模型实现。

第一次是因为兼容老版本的JDK,这个模型在1.2版本才出现,但是ClassLoader在1.0就存在了。添加了一个protected方法findClass(),之前版本继承ClassLoader唯一目的就是重写loadClass方法,因为虚拟机会去调用loadClassInternal方法,这个方法就是执行loadClass()。1.2之后就不提倡重写loadClass了,而是写findClass方法,因为loadClass的基本逻辑已经写好,父类加载失败,就会调用自己的findClass进行加载,这样保证新写出来的类加载器是符合双亲委派模型的。

第二次破坏是因为双亲委派模型自身的缺陷,作为顶层的类通常是被底层类调用的,但是如果顶层的基本类调用了底层的用户类就麻烦大了。顶层的类加载器并不认识用户的类,这样如何去加载这个类呢?典型的例子就是JNDI服务,其目的是对资源进行集中管理和查找,需要调用应用程序的classpath的代码。 为了解决这个问题,设计了一个线程上下文加载器Thread Context ClassLoader。这个类加载器可以通过Thread类的setContextClassLoader方法进行设置,如果创建线程的时候没有设置,将会从父线程中继承一个,全局范围都没有设置,默认就是应用程序类加载器。设置了这个,就可以在父加载器中获取子加载器,让其进行加载类了。

第三次被破坏是由于追求动态性导致的。比如:代码热替换,模块热部署等。 深入探索 Java 热部署

Java内存模型与线程

由于计算机的存储设备与处理器的运算速度有着几个数据量的差异,现代计算机系统不得不加入一层或者多层读写速度尽可能接近处理器运行速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要的数据复制到缓存中,让处理器运算能够快速开始,运算结束后将结果从缓存中同步刷新回主存,保证处理器运算不需要等待缓慢的内存读写。

高速缓存的存在解决了处理器运算快而内存读写慢的矛盾,但是也引入另一个复杂的问题:缓存一致性(Cache Coherence)

Java内存模型

Java虚拟机试图定义一种Java内存模型来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。 解决由于多线程通过共享内存进行通信时,存在的缓存数据不一致编译器会对代码指令重排序处理器会对代码乱序执行等带来的问题。目的是保证并发编程场景中的原子性、可见性和有序性

img

JMM规定所有的变量存储在主内存中(Main Memory),每条线程有自己的工作内存(Working Memory),保存该线程使用的变量的主内存副本(对于基本数据直接复制,对于对象可能复制对象的引用、对象中某个线程访问到的字段而不是直接复制该对象),线程对变量的所有操作(读写)都是在工作内存中进行,而不能直接读写主内存中的数据。

内存间交互

物理机高速缓存和主内存之间的交互有协议,同样的,java内存中线程的工作内存和主内存的交互是由java虚拟机定义了如下的8种操作来完成的,每种操作必须是原子性:

lock(锁定):作用于主内存的变量,一个变量在同一时间只能一个线程锁定,该操作表示这条线成独占这个变量

unlock(解锁):作用于主内存的变量,表示这个变量的状态由处于锁定状态被释放,这样其他线程才能对该变量进行锁定

read(读取):作用于主内存变量,表示把一个主内存变量的值传输到线程的工作内存,以便随后的load操作使用

load(载入):作用于线程的工作内存的变量,表示把read操作从主内存中读取的变量的值放到工作内存的变量副本中(副本是相对于主内存的变量而言的)

use(使用):作用于线程的工作内存中的变量,表示把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时就会执行该操作

assign(赋值):作用于线程的工作内存的变量,表示把执行引擎返回的结果赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的字节码指令时就会执行该操作

store(存储):作用于线程的工作内存中的变量,把工作内存中的一个变量的值传递给主内存,以便随后的write操作使用

write(写入):作用于主内存的变量,把store操作从工作内存中得到的变量的值放入主内存的变量中.

如果要把一个变量从主内存传输到工作内存,那就要顺序的执行read和load操作,如果要把一个变量从工作内存回写到主内存,就要顺序的执行store和write操作。对于普通变量,虚拟机只是要求顺序的执行,并没有要求连续的执行,所以如下也是正确的。对于两个线程,分别从主内存中读取变量a和b的值,并不一样要read a; load a; read b; load b; 也会出现如下执行顺序:read a; read b; load b; load a;

volatile

volatile是一种轻量且在有限的条件下线程安全技术,它保证修饰的变量的可见性和有序性,但非原子性

  • 保证变量的内存可见性: 当一个线程对volatile修饰的变量进行写操作时,JMM会立即把该线程对应的本地内存中的共享变量的值刷新到主内存;当一个线程对volatile修饰的变量进行读操作时,JMM会把立即该线程对应的本地内存置为无效,从主内存中读取共享变量的值。

  • 禁止volatile变量与普通变量重排序:(JSR133提出,Java 5 开始才有这个“增强的volatile内存语义”,提供了一种比锁更轻量级的线程间通信机制。如单写多读模型。

    对于有volatile修饰的变量,在其汇编代码中可以发现多执行了一个lock addl $0x0, (%esp)操作,其作用相当于一个内存屏障(指令重排序时不能把后面的指令重排序到内存屏障之前的位置),它的作用是使得本CPU的Cache写入了内存,且会引起别的CPU无效化其Cache,可让前面volatile变量的修改对其他CPU立即可见。

happens-before

一方面,程序员需要JMM提供一个强的内存模型来编写代码;另一方面,编译器和处理器希望JMM对它们的束缚越少越好,这样它们就可以最可能多的做优化来提高性能,希望的是一个弱的内存模型。 JMM考虑了这两种需求,并且找到了平衡点,对编译器和处理器来说,只要不改变程序的执行结果(单线程程序和正确同步了的多线程程序),编译器和处理器怎么优化都行。 而对于程序员,JMM提供了happens-before规则(JSR-133规范),满足了程序员的需求——简单易懂,并且提供了足够强的内存可见性保证。换言之,程序员只要遵循happens-before规则,那他写的程序就能保证在JMM中具有强的内存可见性。

happens-before关系的定义如下:

  1. 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
  2. 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么JMM也允许这样的重排序。

在Java中,有以下天然的happens-before关系:

  • 程序顺序规则:一个线程中的每一个操作,happens-before于该线程中的任意后续操作。
  • 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
  • volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
  • 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
  • start规则:如果线程A执行操作ThreadB.start()启动线程B,那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作、
  • join规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。

案例

private int value = 0;

public void setValue(int value){
    this.value = value;
}

public int getValue(){
    return this.value
}

此时存在线程A和B,线程A时间上先调用了setValue然后线程B调用了同一个对象的getValue,那么B线程收到的返回值是什么?

由于没有满足happens-before原则,即使线程A在时间上先执行,实际上执行的时候无法确定A\B间的先后顺序,也无法确定线程B 能够获取的值是什么。

我们可以通过synchronized方法套用监视器锁规则或者使用volatile修饰value套用volatile关键字场景以实现先行发生关系。

线程安全与锁优化

线程安全

当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的。

实现方法

互斥同步

同步指的是在多个线程并发访问共享数据时,保证共享数据同一时刻只能被一个线程使用。互斥是实现同步的手段,临界区、互斥量和信号量都是主要的实现互斥的方式。

在Java中,最基本的互斥手段就是synchronized关键字,synchronized在编译后会在同步代码块的前后分别形成monitorenter和monitorexit两个字节码指令,这两个指令都需要一个reference类型的参数来指明要锁定和解锁的对象。在执行monitoenter过程中,首先尝试获取对象的锁。如果这个对象没有被锁定,或者当前线程已经拥有了这个对象锁,把锁的计数器加一,相应地,在执行monitorexit指令时会将锁计数器减一,当计数器为0时,锁就被释放。如果获取锁对象失败,那么当前线程则会阻塞等待,直到锁对象被另一个线程释放为止

ReentrantLock 和 Synchronized类似,一个表现为API 层面上的互斥(lock 和 unlock 方法),一个表现为原生语法层面上的互斥。ReentrantLock 比 Synchronized增加了一些高级功能。

  • 等待中断:持有锁的线程长期不释放锁(执行时间长的同步块)的时候,正在等待的线程可以选择放弃等待,做其他事情。
  • 实现公平锁:ReentrantLock 默认是非公平的,通过构造参数可设置为公平锁,Synchronized是非公平的
  • 锁可以绑定多个条件,ReentrantLock对象可以同时绑定多个Condition对象,而在synchronized中,锁对象的notify()和wait()方法可以实现一个隐含的条件,如果和多于一个的条件关联时,必须加锁,而RenentrantLock多次调用newCondition()即可。

非阻塞同步

互斥同步是一种悲观的同步策略,认为只要不去做正确的同步措施,那就肯定会出现问题,无论共享数据是否发生冲突它都要进行加锁、用户核心态转换、维护锁计数器等操作。非阻塞同步是一种乐观同步策略,基于一种冲突检测的策略,就是先进行操作,如果没有冲突,没有其他线程争抢共享数据,那就操作成功,如果存在冲突,则进行其他补救措施(例如常用的不断的重试,直到成功为止)。

依靠“硬件指令集的发展”,尤其是比较并交换(CAS)指令的发展,乐观并发策略得以执行。CAS指令需要三个操作数,分别是内存位置(变量的内存地址)V,旧的预期值A和新值B。CAS指令执行时,当且仅当V符合预期值A 时,处理器会用新值B更新V,否则它就不执行,这个过程是一个原子操作。在JDK1.5之后,Java程序才可以使用CAS操作,该操作有sun.misc.Unsafe类里面的campareAndSwapint()compareAndSwapLong等方法包装而成。

这里我们以AtomicInteger类的getAndAdd(int delta)方法为例,来看看Java是如何实现原子操作的。

// 真正依赖的实现
private static final jdk.internal.misc.Unsafe U = jdk.internal.misc.Unsafe.getUnsafe();
// 参数中offset,用于获取某个字段相对Java对象的“起始地址”的偏移量
private static final long VALUE = U.objectFieldOffset(AtomicInteger.class, "value");

public final int getAndAdd(int delta) {
    return U.getAndAddInt(this, VALUE, delta);
}
// Unsafe中被调用的实现
@HotSpotIntrinsicCandidate
public final int getAndAddInt(Object o, long offset, int delta) {
    int v;
    do {
        // 获取原值,然后在原值基础上更新,更新失败更新原值后再次尝试更新
        // 一直更新失败一直尝试更新
        v = getIntVolatile(o, offset);
    } while (!weakCompareAndSetInt(o, offset, v, v + delta));
    return v;
}
// 循环条件
public final boolean weakCompareAndSetInt(Object o, long offset,
                                          int expected,
                                          int x) {
    return compareAndSetInt(o, offset, expected, x);
}
// 最终还是native方法
public final native boolean compareAndSetInt(Object o, long offset,
                                             int expected,
                                             int x);

看到它是在不断尝试去用CAS更新。如果更新失败,就继续重试。那为什么要把获取“旧值”v的操作放到循环体内呢?其实这也很好理解。前面我们说了,CAS如果旧值V不等于预期值E,它就会更新失败。说明旧的值发生了变化。那我们当然需要返回的是被其他线程改变之后的旧值了,因此放在了do循环体内。

CAS带来的三大问题
  1. ABA问题

    所谓ABA问题,就是一个值原来是A,变成了B,又变回了A。这个时候使用CAS是检查不出变化的,但实际上却被更新了两次。

    ABA问题的解决思路是在变量前面追加上版本号或者时间戳。从JDK 1.5开始,JDK的atomic包里提供了一个类AtomicStampedReference类来解决ABA问题。这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果二者都相等,才使用CAS设置为新的值和标志。

  2. 循环时间长开销大

    CAS多与自旋结合。如果自旋CAS长时间不成功,会占用大量的CPU资源。

    解决思路是让JVM支持处理器提供的pause指令

    pause指令能让自旋失败时cpu睡眠一小段时间再继续自旋,从而使得读操作的频率低很多,为解决内存顺序冲突而导致的CPU流水线重排的代价也会小很多。

  3. 只能保证一个共享变量的原子操作

    • 使用JDK 1.5开始就提供的AtomicReference类保证对象之间的原子性,把多个变量放到一个对象里面进行CAS操作;
    • 使用锁。锁内的临界区代码可以保证只有当前线程能操作。

无同步方案-ThreadLocal

ThreadLocal叫做线程变量,意思是ThreadLocal中填充的变量属于当前线程,该变量对其他线程而言是隔离的。ThreadLocal为变量在每个线程中都创建了一个副本,那么每个线程可以访问自己内部的副本变量。

使用场景:变量在线程之间无需可见共享,为线程独有;变量创建和使用在不同的方法里且不想过度重复书写形参

原理:

public void set(T value) {
    //(1)获取当前线程(调用者线程)
    Thread t = Thread.currentThread();
    //(2)以当前线程作为key值,去查找对应的线程变量,找到对应的map
    ThreadLocalMap map = getMap(t);
    //(3)如果map不为null,就直接添加本地变量,key为当前线程,值为添加的本地变量值
    if (map != null)
        map.set(this, value);
    //(4)如果map为null,说明首次添加,需要首先创建出对应的map
    else
        createMap(t, value);
}

public T get() {
    //(1)获取当前线程
    Thread t = Thread.currentThread();
    //(2)获取当前线程的threadLocals变量
    ThreadLocalMap map = getMap(t);
    //(3)如果threadLocals变量不为null,就可以在map中查找到本地变量的值
    if (map != null) {
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    //(4)执行到此处,threadLocals为null,调用该更改初始化当前线程的threadLocals变量
    return setInitialValue();
}

private T setInitialValue() {
    //protected T initialValue() {return null;}
    T value = initialValue();
    //获取当前线程
    Thread t = Thread.currentThread();
    //以当前线程作为key值,去查找对应的线程变量,找到对应的map
    ThreadLocalMap map = getMap(t);
    //如果map不为null,就直接添加本地变量,key为当前线程,值为添加的本地变量值
    if (map != null)
        map.set(this, value);
    //如果map为null,说明首次添加,需要首先创建出对应的map
    else
        createMap(t, value);
    return value;
}

锁优化

https://hitomeng.gitee.io/java-notes/2020/04/19/Java%E5%B9%B6%E5%8F%91%E7%BC%96%E7%A8%8B/#more

你可能感兴趣的:(深入理解JVM)