Java-JVM知识总结

Java-JVM知识总结

转载声明

本文大量内容系转载自以下文章,有删改,并参考其他文档资料加入了一些内容:

0x01 JVM运行时内存结构

1.1 运行时数据区域概述

JVM运行时数据区域
JVM内存结构主要有三大块:堆内存、方法区和栈。

JVM运行时数据区和系统调用、类加载器等的关系

1.2 heap堆

1.2.1 概述

  • 线程共享,在虚拟机启动时创建
  • Java堆内存是JVM中最大的一块,由年轻代和老年代组成,而年轻代内存又被分成三部分,Eden空间、From Survivor空间、To Survivor空间,默认情况下年轻代按照8:1:1的比例来分配;
  • 根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(HotSpotJVM通过-Xmx和-Xms控制)。
  • OOM异常
    如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。

1.2.2 组成

  • JVM堆用于存放对象实例和数组,绝大多数创建的对象都会被存放到这里(除了部分由于逃逸分析而在堆外分配的对象,该部分只是在方法体被引用,故被分配到了栈上)。
  • 垃圾回收器最主要针对的对象,对这部分的回收效率影响了JVM的整体性能。
  • 堆中还包括Class对象(JDK1.7+),Class对象是java.lang.Class的实例,代表各个类。而类的元数据,即类的方法代码,变量名,方法名,访问权限,返回值等等都是在方法区。
  • JDK1.8后

1.2.3 TLAB

1.2.3.1 简介

因为JDK1.6之前,多线程在堆上创建对象申请内存时,通过锁或指针碰撞方式来确保不会申请到同一块内存,且JVM Runtime内存分配机器频繁,所以这样会降低性能。

Hotspot JDK1.6引入了TLAB(ThreadLocalAllocBuffer),即JVM堆内部会划分出多个线程私有的分配缓冲区。如果设置了虚拟机参数 -XX:UseTLAB,在线程初始化时,同时也会申请一块指定大小的内存,只给当前线程使用,这样每个线程都单独拥有一个Buffer,如果需要分配内存,就在自己的Buffer上分配,这样就不存在竞争的情况,可以大大提升分配效率,当Buffer容量不够的时候,再重新从Eden区域申请一块继续使用,这个申请动作还是需要原子操作的。

说白了,TLAB的目的就是在为新对象分配内存空间时,让每个Java应用线程能使用自己专属的分配指针来分配空间,均摊对GC堆(eden区)里共享的分配指针做更新而带来的同步开销。

TLAB只是让每个线程有私有的分配指针,但底下存对象的内存空间还是给所有线程访问的,只是其它线程无法在这个区域分配而已。当一个TLAB用满(分配指针top撞上分配极限end了),就新申请一个TLAB,而在老TLAB里的对象还留在原地什么都不用管——它们无法感知自己是否是曾经从TLAB分配出来的,而只关心自己是在eden里分配的。

1.2.3.2 TLAB指针

TLAB数据结构如下:

class ThreadLocalAllocBuffer: public CHeapObj<mtThread> {
  // address of TLAB
  HeapWord* _start; 
  // address after last allocation                            
  HeapWord* _top;
  // allocation prefetch watermark                                
  HeapWord* _pf_top;
  // allocation end (excluding alignment_reserve)                             
  HeapWord* _end;
  // desired size   (including alignment_reserve)                               
  size_t    _desired_size;
  // hold onto tlab if free() is larger than this                       
  size_t    _refill_waste_limit;                 
  .....................省略......................
}

TLAB简单来说本质上主要就是三个指针:start,top 和 end:

  • start 和 end
    是占位用的,end指针表示TLAB空间的结束地址。他们一起标识出 eden 里被这个 TLAB 所管理的区域,卡住eden里的一块空间说其它线程别来这里分配了。
  • top
    当进行对象的内存划分的时候,就会通过移动top指针分配内存,一开始指向跟 start 同样的位置,然后逐渐分配,直到再要分配下一个对象就会撞上 end 的时候就会触发一次 TLAB refill。
  • 注意“撞上”指的是在某次分配请求中,top + new_obj_size >= end 的情况,也就是说在被判定“撞上”的时候,top 常常离 end 还有一段距离,只是这之间的空间不足以满足新对象的分配请求 new_obj_size 的大小。这意味着在触发TLAB refill的时候,有可能会浪费掉位于该TLAB末尾的一部分空间:该TLAB已经占用了这块空间所以其它线程无法在这里分配Java对象,但该TLAB要refill的话它自己也不会在这块空间继续分配Java对象,从应用层面看这块空间就浪费了。
1.2.3.3 TLAB内涵

要注意TLAB这个词其实有两层意思:

  • 一个是指存在管理Java线程的元数据对象 JavaThread 里的 ThreadLocalAllocBuffer对象,它持有上述三个指针,仅用于管理用而不存储对象自身;
  • 另一个是指在eden中分配出来的、被一个线程的ThreadLocalAllocBuffer所管理的一块空间,这才是实际存放对象的地方。本讨论不特地指出的时候会自由混用这两层意思,把它们当作一个整体来看待。
1.2.3.4 refill

TLAB refill包括下述几个动作:

  • 将当前TLAB抛弃(retire)掉。**这个过程中最重要的动作是将TLAB末尾尚未分配给Java对象的空间(浪费掉的空间)分配成一个假的“filler object”(目前是用int[]作为filler object)。**这是为了保持GC堆可以线性parse(heap parseability)用的。
  • 从eden新分配一块裸的空间出来(这一步可能会失败)
  • 将新分配的空间范围记录到ThreadLocalAllocBuffer里
    如果TLAB refill不成功(eden没有足够空间来分配这个新TLAB)就会触发YGC。
1.2.3.5 TLAB大小非固定

每次分配TLAB的大小不是固定的,而是每个线程根据该线程启动开始到现在的历史统计信息来自己单独调整的。

如果一个线程上跑的代码的内存分配速率非常高,则该线程会选择使用更大的TLAB以达到均摊同步开销的效果,反之亦然;同时它还会统计浪费比例,并且将其放入计算新TLAB大小的考虑因素当中,把浪费比例控制在一定范围内。

由于TLAB空间一般不会很大,因此大对象无法在TLAB上进行分配,总是会直接分配在堆上。

TLAB空间由于比较小,因此很容易装满。比如,一个100K的TLAB空间,已经使用了80KB,当需要再分配一个30KB的对象时,就无法分配。这时JVM会有两种选择:

  • 第一,废弃当前TLAB,这样就会浪费20KB空间;
  • 第二,将这30KB的对象直接分配在堆上,保留当前的TLAB,这样可以希望将来有小于20KB的对象分配请求可以直接使用这块空间。

实际上虚拟机内部会维护一个叫作refill_waste的值,当请求对象大于refill_waste时,会选择在堆中分配;若小于该值,则会废弃当前TLAB,新建TLAB来分配对象。

TLABRefillWasteFraction可调整refill_waste阈值,它表示TLAB中允许产生这种浪费的比例。默认值为64,即表示使用约为1/64的TLAB空间作为refill_waste。默认情况下,TLAB和refill_waste都会在运行时不断调整的,使系统的运行状态达到最优。如果想要禁用自动调整TLAB的大小,可以使用-XX:-ResizeTLAB禁用ResizeTLAB,并使用-XX:TLABSize手工指定一个TLAB的大小。

-XX:+PrintTLAB可以跟踪TLAB的使用情况。一般不建议手工修改TLAB相关参数,推荐使用虚拟机默认行为。

1.2.3.6 TLAB配置

参数-XX:+UseTLAB开启TLAB,默认是开启的。

TLAB空间的内存非常小,缺省情况下仅占有整个Eden空间的1%,当然可以通过选项-XX:TLABWasteTargetPercent设置TLAB空间所占用Eden空间的百分比大小。

1.2.3.7 PLAB

HotSpot里的TLAB是只在eden里分配的,用于给新建的小对象用。(本来其实也有考虑让TLAB在任意位置分配,但后来没实现)。PLAB则是在old gen里分配的一种临时的结构。即promotion LAB。

在多GC线程并行做YGC的时候,大家都要为了晋升对象而在old gen里分配空间,于是old gen的分配指针就热起来了。大量的竞争会使得并行度降低,所以跟TLAB用同样的思路,old gen在处理YGC的晋升对象的分配也一样可以用(GC)线程私有的分配区,即PLAB

另外在CMS里old gen的剩余空间不是连续的,而是有很多空洞。这些剩余空间是通过freelist来管理的。(GC做某些需要线性扫描堆里的对象的操作时,需要知道堆里哪些地方有对象而哪些地方是空洞,CMS老年代是使用外部数据结构,例如freelist或者allocation BitMap之类来记录哪里有空洞;)

如果ParNew要把对象晋升到CMS管理的old gen,不优化的话就得在freelist上做分配。于是就可以通过类似PLAB的方式,每个GC线程先从freelist申请一块大空间,然后在这块大空间里线性分配(bump pointer)。这样就既降低了对分配指针/freelist的竞争,又可以降低freelist分配的频率而转为用线性分配。

1.3 JVMStack-栈

JVM栈

1.3.1 概述

  • 线程私有
  • 生命周期
    JVM栈生命周期与线程相同。
  • 栈帧描述Java方法执行的内存模型
    每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程
  • 当前栈帧和当前方法
    因为线程的方法调用链可能很长,即很多方法都处于执行中,但只有栈顶的栈帧才是有效的,称为当前栈帧,与之关联的方法称为当前方法。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。
  • 异常
    在Java虚拟机规范中,对这个区域规定了两种异常状况:
  • StackOverflowError
    如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;
  • OutOfMemoryError
    如果虚拟机栈可以动态扩展(大部分虚拟机支持),当扩展时无法申请到足够的内存时会抛出异常。

1.3.2 组成

  • 局部变量表
    存放了编译期(此时就确定了局部变量表大小)可知的方法中的参数和定义的局部变量,包括各种基本数据类型(boolean、byte、等)、对象引用(reference类型,它不等同于对象本身,根据不同的虚拟机实现,它可能是一个指向对象起始地址的引用指针,也可能指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。

    JVM通过索引定位方式使用局部变量表,从0到最大Slot。其中64位长度的long和double类型的数据会占用2个连续的局部变量空间(Slot),其余的数据类型只占用1个slot。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小

    局部变量没有准备阶段,也就是说不会自动给基本类型的局部变量赋初始零值。

    局部变量表在编译阶段就将最大容量设到了方法的Code属性的max_locals中。

    在方法执行时,如果是实例方法,即非static方法,局部变量表中第0位Slot默认存放对象实例的引用,在方法中可以通过关键字 this 进行访问,方法参数按照参数列表顺序,从第1位Slot开始分配,方法内部变量则按照定义顺序进行分配其余的Slot。
    局部变量表

  • 操作数栈
    后入先出。操作数栈在编译阶段就将最大深度设到了方法的Code属性的max_stacks数据项中,方法执行时操作数栈深度永不会超过该值。

    方法初始执行时,该方法的操作数栈为空,随着方法执行会将各种字节码指令出入栈,比如iadd指令就是将栈顶的两个int型数据出栈然后相加,并把结果入栈。

    为了优化,大多虚拟机会令两个栈帧出现部分重叠,让下面的栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠,以使得方法调用时可共用部分数据,无需进行额外的参数复制传递。

栈帧优化优化

  • 动态链接
    每个栈帧都包含了一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接。

    字节码方法调用指令就以常量池中指向方法的符号引用作为参数。静态解析就是指在类加载或首次使用时就转为直接引用;动态链接指在每次运行期才转直接引用。

  • 返回地址
    当一个方法开始执行以后,只有两种方法可以退出当前方法:

    1. 方法正常完成出口
      当执行引擎遇到方法的任一字节码返回指令,可能会有返回值传递给上层的方法调用者。一般来说,调用者的PC计数器可以作为返回地址(可能记录到栈帧)。
    2. 方法异常出口
      当执行遇到异常,且当前方法没有合适的异常处理器来处理,就会导致方法退出,此时没有返回值,称为异常完成出口,返回地址要通过异常处理器表来确定(栈帧一般不保存)

    方法退出的过程就是将当前栈帧出栈,可能进行3个操作:
    1. 恢复上层方法的局部变量表和操作数栈
    2. 把返回值压入调用者调用栈帧的操作数栈
    3. 调整PC计数器的值以指向方法调用指令后面的一条指令

1.4 NativeStack-本地方法栈

  • 线程私有
  • 用涂
    主要用于JVM的Native方法。这部分是有JVM自行管理,程序员基本上不需要关心该部分。
  • 与JVM栈区别
  • 本地方法栈与JVM栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。
  • 具体实现
    虚拟机规范中对本地方法栈中的方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一
  • 异常
    与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。

1.5 MethodArea-方法区

1.5.1 概述

  • 线程共有
    因为方法区是被所有线程共享的,所以必须考虑数据的线程安全。假如两个线程都在试图找lava的类,在lava类还没有被加载的情况下,只应该有一个线程去加载,而另一个线程等待。

  • 方法区的构建
    在类加载时,会通过类的全限定名加载class文件,构建二进制字节流,将该字节流代表的静态存储结构转化为方法区的运行时数据结构。最后生成代表该类的Class对象放入内存,该Class对象就是该类的访问入口。

  • 组成

    • JVM加载的类信息:即类的全限定名、直接父类的完整有效名(除非这个类型是interface或是java.lang.Object,两种情况下都没有父类)、直接接口的有序列表、方法代码,变量名,方法名,访问权限(public/abstract等),返回值等
    • 常量、静态变量
    • JIT编译产生的代码等数据
  • 非堆
    为与Java堆区分,方法区还有一个别名Non-Heap(非堆)

  • 方法区、永久代、元空间
    对于习惯在HotSpot虚拟机上开发和部署程序的开发者来说,很多人愿意把方法区称为“永久代”(Permanent Generation),本质上两者并不等价,仅仅是因为HotSpot虚拟机的设计团队选择把GC分代收集扩展至方法区,或者说使用永久代来实现方法区而已。

    JDK1.8以后用元空间替换永久区实现,因为使用永久代实现方法区更容易内存溢出。字符串常量池已经移动到堆

  • 方法区的内存回收
    Java虚拟机规范对这个区域的限制非常宽松,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。相对而言,GC行为在这个区域是比较少出现的,但并非数据进入了方法区就“永久”存在。

    方法区的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收“成绩”比较难以令人满意,尤其是类型的卸载,条件相当苛刻,但是回收确实是有必要的。

  • OOM异常
    根据Java虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。

1.5.2 方法区变迁

JVM规范虽说编译后代码在方法区,但是不做强制要求,具体要看JVM实现,hotspot将JIT编译生成的代码存放在native memory的CodeCache区域

1.5.2.1 jdk6

Klass元数据信息
每个类的运行时常量池(字段、方法、类、接口等符号引用)、编译后的代码
静态字段(无论是否有final)在instanceKlass末尾(位于PermGen内)
oop 其实就是Class对象实例
全局字符串常量池StringTable,本质上就是个Hashtable
符号引用(类型指针是SymbolKlass)

1.5.2.2 jdk7

Klass元数据信息
每个类的运行时常量池(字段、方法、类、接口等符号引用)、编译后的代码
静态字段从instanceKlass末尾移动到了java.lang.Class对象(oop)的末尾(位于普通Java heap内)
oop(Class对象实例)与全局字符串常量池移到java heap上
符号引用被移动到native heap中

1.5.2.3 jdk8

移除永久代:

新增元空间(Metaspace)
Klass元数据信息、每个类的运行时常量池、编译后的代码移到了另一块与堆不相连的本地内存即元空间
参数控制-XX:MetaspaceSize与-XX:MaxMetaspaceSize。

方法区在元空间,Class实例在JavaHeap
关于openjdk移除永久代的相关信息:http://openjdk.java.net/jeps/122

1.5.3 运行时常量池

  • JVM为每个已加载的类都维护一个常量池,存放编译期生成的各种字面量和符号引用。
  • 常量池就是这个类用到的常量的一个有序集合,包括实际的常量(string, integer, 和floating point常量)和对类,field和方法的符号引用
  • 常量池中的数据项像数组项一样,是通过索引访问的。
  • 因为常量池存储了一个类型所使用到的所有类型,field和方法的符号引用,所以在java程序的动态链接中起了核心的作用。
  • 可以使用String.intern方法从常量池中获取字符串String引用,或是放入常量池并返回该引用。(JDK1.8以后字符串常量池已经移入Java Heap)

1.5.4 Filed信息

JVM必须在方法区中保存类的所有Filed的相关信息以及Filed的声明顺序,包括:

  • FieldName
  • FieldClass
  • Field修饰符(public, private, protected,static,final volatile, transient的某个子集)

1.5.5 方法信息

JVM必须保存所有方法的以下信息,同样域信息一样包括声明顺序:

  • 方法名
  • 方法的返回类型(或 void) 方法参数的数量和类型(有序的)
  • 方法的修饰符(public, private, protected, static, final, synchronized, native, abstract的一个子集)
  • 除了abstract和native方法外,其他方法还有保存方法的字节码(bytecodes)操作数栈和方法栈帧的局部变量区的大小

1.5.6 类变量和常量

non-final类变量被存储在声明它的类信息内,位于方法区;而final类变量被存储在所有使用它的类信息的常量池内

  • 类变量即类的非final静态变量,被类的所有实例共享,即使没有类实例时你也可以访问它。这些变量只与类相关,所以在方法区中,它们成为类数据在逻辑上的一部分。在JVM使用一个类之前,它必须在方法区中为每个non-final类变量分配空间。
  • 每个常量(被声明为final的类变量)都会在常量池中有一个拷贝。

1.5.7 对ClassLoader的引用

JVM必须知道一个类是由BootstrapClassLoader加载的还是由AppClassLoader加载的。如果一个类由AppClassLoader加载,那么JVM会将这个ClassLoader的一个引用作为类信息的一部分保存在方法区中。

JVM在动态链接的时候需要这个信息。当解析一个类型到另一个类型的引用的时候,JVM需要保证这两个类型的类加载器是相同的。这对JVM区分namespace的方式是至关重要的。

1.5.8 对Class类的引用

JVM会为每个加载的类型(包括类和接口)都在堆中创建一个java.lang.Class的实例,并将该Class实例和存储在方法区中的类型数据联系起来。

你可以通过Class类的一个静态方法得到这个实例的引用

// A method declared in class java.lang.Class:
public static Class forName(String className);

你甚至可以通过这个函数得到任何包中的任何已加载的类引用,只要这个类能够被加载到当前的namespace。如果jvm不能把类加载到当前名字空间,forName就会抛出ClassNotFoundException。

也可以通过任一对象的getClass()函数得到类对象的引用:

// A method declared in class java.lang.Object: 
public final Class getClass(); 

通过类对象的引用,你可以在运行中获得相应类存储在方法区中的类型信息,下面是一些Class类提供的方法:

// Some of the methods declared in class java.lang.Class: 
// 返回类的完整名
public String getName(); 
// 返回父类的Class对象
public Class getSuperClass(); 
public boolean isInterface(); 
// 返回直接父接口数组
public Class[] getInterfaces(); 
public ClassLoader getClassLoader();

以上所有的这些信息都直接从方法区中获得。

1.5.9 方法表

为了提高访问效率,必须仔细的设计存储在方法区中的数据信息结构。除了以上讨论的结构,JVM的实现者还可以添加一些其他的数据结构,如方法表。

JVM对每个加载的非虚拟类的类型信息中都添加了一个方法表,方法表是一组对类实例方法的直接引用(包括从父类继承的方法)。JVM可以通过方法表快速激活实例方法。

1.5.10 方法执行时使用查找方法表示例

class Lava { 
	private int speed = 5; // 5 kilometers per hour 
	void flow() { 
	} 
}

class Volcano { 
	public static void main(String[] args) { 
	Lava lava = new Lava(); 
	lava.flow(); 
	} 
} 
  1. 为了运行这个程序,你以某种方式把“Volcano”传给了jvm。

  2. 有了这个名字,JVM找到了这个类文件(Volcano.class)并读入,它从类文件提取了类型信息并放在了方法区中,

  3. 通过解析存在方法区中的字节码,JVM激活了main()方法,在执行时,jvm保持了一个指向当前类(Volcano)常量池的指针。

  4. main()的第一条指令告知JVM,需要为列在常量池第一项的类分配足够的内存。JVM使用指向Volcano常量池的指针找到第一项,发现是一个对Lava类的符号引用,然后它就检查方法区看lava是否已经被加载了。这个符号引用仅仅是类lava的完整有效名”lava“。这里我们看到为了jvm能尽快从一个名称找到一个类,一个良好的数据结构是多么重要。这里jvm的实现者可以采用各种方法,如hash表,查找树等等。同样的算法可以用于Class类的forName()的实现。

  5. 当jvm发现还没有加载过一个称为”Lava”的类,它就开始查找并加载类文件”Lava.class”。它从类文件中抽取类型信息并放在了方法区中。

  6. jvm于是以一个直接指向方法区lava类的指针替换了常量池第一项的符号引用。以后就可以用这个指针快速的找到lava类了。而这个替换过程称为常量池解析(constant pool resolution)。在这里我们替换的是一个native指针。

  7. jvm终于开始为新的lava对象分配空间了。这次,jvm仍然需要方法区中的信息。它使用指向lava数据的指针(刚才指向volcano常量池第一项的指针)找到一个lava对象究竟需要多少空间。jvm总能够从存储在方法区中的类型信息知道某类型对象需要的空间。但一个对象在不同的jvm中可能需要不同的空间,而且它的空间分布也是不同的。一旦jvm知道了一个Lava对象所要的空间,它就在堆上分配这个空间并把这个实例的变量speed初始化为缺省值0。假如lava的父对象也有实例变量,则也会初始化。

  8. 当把新生成的lava对象的引用压到线程栈中,第一条指令也结束了。

  9. 下面的指令利用这个引用激活java代码,把speed变量设为初始值,5。

  10. 另外一条指令会用这个引用激活Lava对象的flow()方法。

1.5.11 元空间

Java8增加了元空间替代了方法区,移除了永久代。元空间特点如下:

  • 充分利用了Java语言规范中的好处:类及相关的元数据的生命周期与类加载器的一致。
  • 每个加载器有专门的存储空间
  • 只进行线性分配
  • 不会单独回收某个类
  • 省掉了GC扫描及压缩的时间
  • 元空间里的对象的位置是固定的
  • 如果GC发现某个类加载器不再存活了,会把相关的空间整个回收掉

1.6 Program Counter Register-程序计数器

1.6.1 概述

  • 行号指示器
    程序计数器是一块较小的内存空间,可看做是当前线程执行字节码的行号指示器。在虚拟机的概念模型里(仅是概念模型,各种虚拟机可能会通过一些更高效的方式去实现),字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
  • 线程私有
    由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
  • 方法与内容
    • 如果线程正在执行的是Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;
    • 如果正在执行的是Natvie方法,这个计数器值则为空(Undefined)。
  • 无OOM异常
    此内存区域是唯一在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

1.7 Compressed Class Pointer Space-压缩指针空间

JVM中,每个对象都有一个指向它自身类的指针,不过这个指针只是指向具体的实现类,而不是接口或者抽象类。

1.7.1 32bit JVM

  • _mark : 4字节常量
  • _klass: 指向类的4字节指针 对象的内存布局中的第二个字段( _klass,在32位JVM中,相对对象在内存中的位置的偏移量是4,64位的是8)指向的是内存中对象的类定义。

1.7.2 64bit JVM:

  • _mark : 8字节常量
  • _klass: 指向类的8字节的指针
    对于64位平台,为了压缩JVM对象中的_klass指针的大小,引入了类指针压缩空间(Compressed Class Pointer Space)就是为了省内存,看下面这幅图就秒懂了:
    Compressed Class Pointer Space

1.8 直接内存

直接内存并不属于JVM运行时数据区或JVM规范定义的区域,但其被频繁使用,可能导致OOM。

可以使用DirectByteBuffer对象作为分配的对外内存的引用进行操作,可在某些场景提高性能,避免在Java堆和Native堆中来回复制数据。

要注意直接内存受机器内存制约,所以必须引起重视避免因为总内存超了,导致JVM内存抛出OOM。

1.9 综合例子

JVM内存情况

  • 所有的对象在实例化后的整个运行周期内,都被存放在堆内存中。
  • 方法的执行都是伴随着线程的。基本类型的本地变量以及引用都存放在线程栈中。
  • 而引用关联的对象比如String,都存在在堆中。为了更好的理解上面这段话,我们可以看一个例子:
import java. text . SimpleDateFormat;
import java . util .Date;
import java.util.logging.Logger;;
public class HelloWorld {
	private static Logger LOGGER = Logger .getLogger(HelloWorld.class);
	public void sayHello(String message) {
		SimpleDateFormat formatter = new SimpleDateFormat("dd.MM.YY");
		String today = formatter.format(new Date()); .
		LOGGER. info(today + ": " + message);
	}
}

这段程序的数据在内存中的存放如下:
JVM内存情况例子
注意,上图中方法区中的Class对象1.7以后已经移入堆了。

1.10 小结

下面是一个关于每个Java内存区域的带解释的详图:
Java内存区域解释

Java8增加了元空间替代了方法区,移除了永久代。

0x02 Java对象

2.1 对象模型-OOP-Klass

2.1.1 概述

在JVM中,使用了OOP-KLASS模型来表示java对象,OOP(Ordinary Object Pointer)指的是普通对象指针,而Klass用来描述对象实例的具体类型,即:

  1. instanceKlass-对象类型数据
    JVM在加载每个类时,会为该类创建一个instanceKlass,保存在方法区,在JVM层表示该Java类。

    instanceKlass包含其元数据:包括常量池、字段、方法等,存放在方法区;

  2. instanceOopDesc-对象实例数据
    在new一个对象时,JVM创建instanceOopDesc,来表示这个对象。

    instanceOopDesc存放在堆区,而其引用存放在线程栈内;它用来表示对象的实例信息,看起来像个指针实际上是藏在指针里的对象;instanceOopDesc对应java中的对象实例,包含了对象头以及实例数据;

  3. Class对象与instanceKlass不同
    HotSpot并不把instanceKlass暴露给Java,而会另外创建对应的instanceOopDesc来表示java.lang.Class对象,并将后者称为前者的“Java镜像”

    klass持有指向oop引用(_java_mirror便是该instanceKlass对Class对象的引用);

  4. 实例——>instanceKlass——>Class。
    new操作返回的instanceOopDesc类型指针指向instanceKlass;而instanceKlass指向了对应的类型的Class实例的instanceOopDesc;

    有点绕,简单说,就是Person实例——>Person的instanceKlass——>Person的Class。

Java对象模型

instanceOopDesc,只包含数据信息,它包含三部分:

  1. 对象头,也叫Mark Word,主要存储对象运行时记录信息,如hashcode, GC分代年龄,锁状态标志,线程ID,时间戳等;
  2. 元数据指针,即指向方法区的instanceKlass实例
  3. 实例数据;
  4. 另外,如果是数组对象,还多了一个数组长度

2.1.2 Class类

在Java中用来表示Class类来表示对象的运行时类型信息,每个Java类都有一个Class对象(编译后)。

当new实例化一个对象或引用类的静态变量时,JVM中的ClassLoader会将对象的对应类的Class对象加载,随后JVM根据该Class对象创建我们关注的对象实例或静态变量的引用值。

ShapesClass

Class类总结如下:

  • Class类也是类的一种,与class关键字是不一样的。
  • Class对象保存在同名类的字节码
  • 手动编写的类被编译后会产生一个Class对象,其表示的是创建类的类型信息,而这个Class对象保存在同名.class的文件中(字节码文件),比如创建一个Shapes类,编译Shapes类后就会创建其包含Shapes类相关类型信息的Class对象,并保存在Shapes.class字节码文件中。
  • 每个类只有一个Class对象
    每个通过关键字class标识的类,在内存中有且只有一个与之对应的Class对象来描述其类型信息,无论创建多少个实例对象,其依据的都是用一个Class对象。
  • Class类构造函数私有
    Class类只存私有构造函数,因此对应Class对象只能有JVM创建和加载
  • Class对象作用
    Class类的对象作用是运行时提供或获得某个对象的类型信息,这点对于反射技术很重要(关于反射稍后分析)。

2.2 对象创建流程

  1. 类符号引用和加载检查
    虚拟机读到new对象指令,检查在常量池是否能定位到某个类的符号引用,以及检查该符号引用代表的类是否已经被加载、解析、初始化,如果没有就走类加载流程。
  2. 分配内存给对象
    类加载后即可确认对象大小,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。有两种分配方式:
    • 指针碰撞(Bump the Pointer)(Serial、ParNew等带Compact过程的收集器)
      假设Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离。
    • 空闲列表(freelist)(CMS这种基于Mark-Sweep算法的收集器)
      如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。
  3. 解决多线程分配
    有两种方式解决多线程下的内存分配问题,一种是同步处理(CAS->同步锁);一种是TLAB,每个线程在自己的TLAB上分配,TLAB用完才分配新的TLAB,此时才需要同步锁。
  4. 初始化零值
    JVM为分配了的内存空间初始化零值(不含对象头),如果使用TLAB就会提前到分配TLAB分配时。该操作可使得直接访问对象的实例字段对应的零值。
  5. 对象头设置
    JVM将如对象所属类、类的元数据信息访问方法、hashcode、GC分代年龄等信息存放到对象头,还有锁信息如偏向锁等。
  6. <init>方法
    此时还没有执行<init>方法,所有字段还为0,而要等到真正执行new指令之后才会接着执行该方法,创造真正完整意义上的对象。
  • 对象分配的规则如下:
    • obj_size + tlab_top <= tlab_end
      即对象大小可直接在TLAB空间分配对象
    • obj_size + tlab_top >= tlab_end && tlab_free > tlab_refill_waste_limit
      对象超出TLAB剩余空间,无法在TLAB分配,而在Eden区分配。(tlab_free:剩余的内存空间,tlab_refill_waste_limit:允许浪费的内存空间)
    • obj_size + tlab_top >= tlab_end && tlab_free < _refill_waste_limit,重新分配一块TLAB空间,在新的TLAB中分配对象

按照上面的规则,如果对象先TLAB划分失败,那么对象会在Eden空间划分对象,首先尝试CAS方式向堆申请锁,如果失败,通过对堆同步加锁的方式进行分配,下面是 new 一个对象的流程图:
对象分配流程

优化策略的分配流程:
对象分配2

CAS分配代码如下:

// 对大小进行判断,比如是否超过eden区能分配的最大大小
if (gen0->should_allocate(size, is_tlab)) {
  // while循环 + 指针碰撞 + CAS分配
  result = gen0->par_allocate(size, is_tlab);
  if (result != NULL) {
    assert(gch->is_in_reserved(result), "result not in heap");
    return result;
  }
}

对堆区进行加锁:

MutexLocker ml(Heap_lock);//锁
// 需要注意的是,只有大对象可以被分配在老年代。
// 一般情况下should_try_older_generation_allocation都是false,所以first_only=true
bool first_only = ! should_try_older_generation_allocation(size);
// 在年轻代分配
result = gch->attempt_allocation(size, is_tlab, first_only);
if (result != NULL) {
  assert(gch->is_in_reserved(result), "result not in heap");
  return result;
}

2.3 对象的内存布局

HotSpot中,对象在内存中的布局如下:

2.3.1 对象头

  • Klass Pointer,即类型指针(用于确定对象属于的类),指向该对象的类元数据。
  • 如果对象是数组,还需要存储数组长度(不是数组大小)。
  • Mark Word,即对象运行时数据。他的内部字节长度分布与含义非固定,节约空间。存有如hashCode、分代年龄、锁标志信息等。

下面是一个32位的HotSpot虚拟机中 MarkWord示意图:
MarkWord

2.3.2 实例数据

对象真正存储的有效信息,即代码中定义的各类型字段内容,包括父类继承的和子类定义的。

2.3.3 对齐填充

非必要,作用是占位符,因为HotSpot要求对象起始地址(大小)必须是8字节的整数倍,而对象头是8字节的1或2倍。所以需要当对象实例数据不够8字节整数倍时,需要加字节填充补足8字节。

2.4 对象在内存的引用方式

对象在内存的引用方式

  • 可以看到,某个对象在栈帧中的局部变量表存有所需对象的引用。我们的线程代码就是用线程栈内的该引用来操作堆上的具体对象。

  • 堆内存的是存了instanceOopDesc

  • 方法区(1.8是元空间)存了KClass,他包含类的各种属性,如类名,内存布局(大小),方法表,父子类关系等

  • 其他虚拟机使用句柄池来定位对象,垃圾收集时对象被移动,只需要改变句柄中的对象实例数据指针,不用改引用地址:
    句柄池

  • HotSpot虚拟机使用直接指针来定位对象,这种方法引用存储的是对象地址。直接指针的好处是速度快,少一次寻址(JVM中查找对象是很频繁的)。
    直接指针

2.5 对象填充

GC做某些需要线性扫描堆对象的操作时,需要知道堆里哪些地方有对象而哪些地方是空洞。

HotSpot把空洞部分也假装成有对象,这样GC在线性遍历时会看到一个“对象总是连续分配的”的假象,就可以以统一的方式来遍历:遍历到一个对象时,通过其对象头记录的信息找出该对象的大小,然后跳到该大小之后就可以找到下一个对象的对象头,依此类推。HotSpot选择的是后者的做法,假装成有对象的这种东西就叫做filler object(填充对象)。

0x03 JMM-Java工作内存模型

由于计算机的存储设备与处理器的运算能力之间有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(cache)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中没这样处理器就无需等待缓慢的内存读写了。

基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是引入了一个新的问题:缓存一致性(Cache Coherence)。在多处理器系统中,每个处理器都有自己的高速缓存,而他们又共享同一主存,如下图所示:多个处理器运算任务都涉及同一块主存,需要一种协议可以保障数据的一致性,这类协议有MSI、MESI、MOSI及Dragon Protocol等。Java虚拟机内存模型中定义的内存访问操作与硬件的缓存访问操作是具有可比性的,后续将介绍Java内存模型。

硬件的效率与一致性

  • 指令重排
    除此之外,为了使得处理器内部的运算单元能竟可能被充分利用,处理器可能会对输入代码进行乱起执行(Out-Of-Order Execution)优化,处理器会在计算之后将对乱序执行的代码进行结果重组,保证结果准确性。与处理器的乱序执行优化类似,Java虚拟机的即时编译器中也有类似的指令重排序(Instruction Recorder)优化。

3.1 概述

注意区分Java工作内存模型和Java运行时数据区域。

JMM(Java Memory Model),即Java内存模型。Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样底层细节。此处的变量与Java编程时所说的变量不一样,指包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,后者是线程私有的,不会被共享

Java堆和方法区是线程共享的,即多个线程可能可以操作保存在堆或者方法区中的同个数据。这也就是所谓“Java的线程间通过共享内存进行通信”,在通信过程中会存在一系列如可见性、原子性、顺序性等问题,JMM就是围绕着多线程通信以及与其相关的一系列特性而建立的模型。

JMM并不像Java运行时数据区域一样是真实存在的,而他只是一个抽象的概念。JSR-133: Java Memory Model and Thread Specification 中描述了,JMM是和多线程相关的,他描述了一组规则或规范,这个规范定义了一个线程对共享变量的写入时对另一个线程是可见的。JMM定义了一些语法集,这些语法集映射到Java语言中就是volatile、synchronized等关键字。
工作内存主内存

3.2 主内存

多个线程间通信的共享内存称为主内存。

在java中,实例域、静态域和数组元素是线程之间共享的数据,它们存储在主内存中。

主内存可勉强对应Java堆中的对象实例数据部分,不包括对象头。

3.3 工作内存-本地内存

每个线程都维护了一个自己的本地内存(这是个抽象概念),其中保存的数据是主内存中的数据拷贝(当然,并不是说将对象全部拷贝,而只是拷贝对象的引用、本线程访问的该对象的目标字段等),线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。

不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要在主内存来完成,而JMM主要是控制本地内存和主内存之间的数据交互的。

局部变量,方法定义参数 和 异常处理器参数是不会在线程之间共享的,它们存储在线程的本地内存中。

volatile也有工作内存拷贝,但是由于其特殊的操作顺序性规定所以才在使用中貌似直接读写主内存。

工作内存可勉强对应虚拟机栈中部分区域。

3.4 主内存和工作内存交互

JMM定义了8种原子类型操作来实现主内存和工作内存之间交互,如将变量从主内存复制到工作内存时,需要顺序执行read和load,写回主内存时顺序执行store和write,但他们并非一定是连续的:

  • lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
  • unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
  • write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。

如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行read和load操作, 如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。Java内存 模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。也就是read和load之间, store和write之间是可以插入其他指令的,如对主内存中的变量a、b进行访问时,可能的顺 序是read a,read b,load b, load a。

Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:

  • 不允许read和load、store和write操作之一单独出现
  • 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。
  • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
  • 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现
  • 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值
  • 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
  • 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。

JMM关于这些原子操作有一些规定,十分繁琐,但有一个等效判断原则-HappenBefore原则,原来确定并发环境下的一个访问是否线程安全。

3.5 指令重排

3.5.1 定义

重排序是指“编译器和处理器”为了提高性能,而在程序执行时会对程序进行的重排序。

3.5.2 指令重排的分类

重排序分为——“编译器”和“处理器”两个方面,而“处理器”重排序又包括“指令级重排序”和“内存的重排序”。也就是说,从Java源代码到最终实际执行的指令序列,会经过下面三种重排序:
指令重排

  • 编译器优化的重排序。
    编译器在不改变单线程程序语义放入前提下,可以重新安排语句的执行顺序。
  • 指令级并行的重排序。
    现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统的重排序。
    由于处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

3.5.3 重排序思想

为了提高程序的并发度,从而提高性能!但是对于多线程程序,重排序可能会导致程序执行的结果不是我们需要的结果!因此,就需要我们通过“volatile,synchronize,锁等方式”作出正确的实现同步。

3.6 内存屏障

3.6.1 例子

为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。内存屏障,也叫做内存栅栏,是一组处理器指令,用于实现对内存操作的顺序限制。

内存屏障是指重排序时不能把后面的指令放到内存屏障之前。包括LoadLoad, LoadStore, StoreLoad, StoreStore共4种内存屏障:
内存屏障
对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,JMM 采取保守策略。下面是基于保守策略的 JMM 内存屏障插入策略:
内存屏障2

3.6.2 例子

public class MemoryBarrier {
    int a, b;
    volatile int v, u;

    void f() {
        int i, j;

        i = a;
        j = b;
        i = v;
        //LoadLoad
        j = u;
        //LoadStore
        a = i;
        b = j;
        //StoreStore
        v = i;
        //StoreStore
        u = j;
        //StoreLoad
        i = u;
        //LoadLoad
        //LoadStore
        j = b;
        a = i;
    }
}

3.7 volatile

3.7.1 主要语义

  • 可见性
    保证volatile修饰的变量对所有线程可见,当某线程修改该变量时,无论是否加锁,对其他线程立刻可见。原理是每次使用该类型变量时都要先到主内存刷新该变量。而普通变量却做不到这点。
  • 禁止指令重排序优化
    普通的变量仅仅保证在该方法的执行过程中所有依赖赋值结果的地方都能获取正确的结果,而不能保证变量赋值操作的顺序与程序代码的执行顺序一致
  • 无原子性,互斥性
    volatile 仅仅解决了可见性的问题,但是它并不能保证互斥性,也就是说多个线程并发修改某个变量时,依旧会产生多线程问题。因此,不能靠volatile 来完全替代传的锁

3.7.2 可见性和原子性

当需要使用被volatile修饰的变量时,线程会从主内存中重新获取该变量的值,但当该线程修改完该变量的值写入主内存的时候,并没有判断主内存内该变量是否已经变化,故可能出现非预期的结果。

如主内存内有被volatile修饰变量 a,值为3,某线程使用该变量时,重新从主存内读取该变量的值,为3,然后对其进行+1操作,此时该线程内a变量的副本值为4。但此时该线程的时间片时间到了,等该线程再次获得时间片的时候,主存内a的值已经是另外的值,如5,但是该线程并不知道,该线程继续完成其未完成的工作,将线程内的a副本的值4写入主存,这时,主存内a的值就是4了。这样,之前修改a的值为5的操作就相当于没有发生了,a的值出现了意料之外的结果。

3.7.2.1 不适用的场景

多线程执行以下代码:

volatile i;
increase(){
	i++;
}

因为increase方法中虽然I为volatile表示,但只能保证在读取时是最新的值,但++操作是多条指令组成,所以并不能保证在运算完成写回主内存过程中没有其他线程改变了该值。

所以volatile此时只能保证可见性,而无法保证原子性。

3.7.2.2 适用的场景

多线程执行以下循环:

volatile boolean stop = false;
while(!stop){
	//work
}

停止方法如下:

stopAll(){
	stop = true;
}

或是在单例模式中:

class Singleton{
	private volatile static Singleton instance = null;
	privateSingleton() {}
	
	public static Singleton getInstance() {
		if(instance==null) {
			synchronized(Singleton.class) {
				if(instance==null)
					instance = newSingleton();
			}
		}
		return instance;
	}
}

如果不用volatile修饰instance,则可能发生意外情况。因为instance = newSingleton()这行代码其实是以下3步:

  1. 给 instance 分配内存
  2. 调用 Singleton 的构造函数来初始化成员变量
  3. 将instance对象指向分配的内存空间(执行完这步 instance 就为非 null 了)。
    而JVM可能对2、3步进行指令重排优化,也就是说可能为1-3-2这种重排情况。此时如果线程1先执行3,但还未执行2,这个时候线程2进来发现instance不为null,直接就返回未初始化完成的instance实例,会发生错误。所以我们必须用volatile来避免指令重排。

3.7.3 指令重排

volatitle保证可见性,有happen before原则,对volatile修饰的变量写happen before于读。

以下是JMM 针对编译器制定的 volatile 重排序规则表:
volatile 重排
从上表我们可以看出:

  • 避免后面的volatile写覆盖较早的写
    当第二个操作是 volatile 写时,不管第一个操作是什么,都不能重排序。这个规则确保 volatile 写之前的操作不会被编译器重排序到 volatile 写之后。
  • 避免前面的volatile读读到后面的写
    当第一个操作是 volatile 读时,不管第二个操作是什么,都不能重排序。这个规则确保 volatile 读之后的操作不会被编译器重排序到 volatile 读之前。

一般volatile适用于强制变量的修改对其他线程可见;此外,volatile还能保证顺序性,避免指令重排导致程序异常,如下程序:

private static boolean ready;
private static int number;
private static volatile boolean ready2;
private static volatile int number2;
private static class ReaderThread extends Thread{
    public void run(){
        while(!ready);
        System.out.println(number);
    }
}
private static class ReaderThread2 extends Thread{
    public void run(){
        while(!ready2);
        System.out.println(number2);
    }
}

public static void main(String[] args) throws InterruptedException {
    new ReaderThread().start();
    new ReaderThread2().start();
    Thread.sleep(1000);
    number = 42;
    ready = true;
    number2 = 422;
    ready2 = true;
    Thread.sleep(10000);
}

最后的结果:

  • ReaderThread线程因为ready变量被指令重拍了,导致读不到最新值而造成死循环.
  • ReaderThread2线程因为ready2变量用volatile修饰,避免了指令重拍,会打出442然后线程结束

3.7.4 volatile和JMM操作特殊规定

  • 可见性保证
    • 读可见
      load后必须和use连续,以保证使用volatile变量时,每次都必须从主内存read该变量最新值load到工作内存,然后才能使用。
    • 写可见
      assign复制操作后必须和store连续,以保证每次在工作内存修改volatile变量值后立刻刷入主内存,保证其他线程看到最新的更改。
  • 顺序性保证

3.7.5 性能以及对比synchronized

  • 对比普通变量
    读性能差不多;volatile写性能较低,因为需要加入内存屏障以禁止指令重排
  • 对比同步锁synchronzied
    大多数情况下volatile更优,但synchronized不断在优化。如果场景满足优先选择volatile。

3.8 原子性

3.8.1 概念

是指一个操作是按原子的方式执行的。要么该操作不被执行;要么以原子方式执行,即执行过程中不会被其它线程中断。

  • 单个JMM操作具有原子性,如read load assign等
  • 更大范围的原子性操作对应JMM内的lock, unlock操作,对应了高层次的monitorenter和monitorexit字节码之灵,对用户来说就是更高层次的synchronized语法。

3.8.2 例子

x = 10;
y = x;
x++;
x = x+1;
  • 语句1是原子性操作。执行该语句的线程直接将赋值写入到自己的工作内存。
  • 语句2不是原子性操作。因为需要先读取x值,再将该值赋值给y写入工作内存。虽然分别是原子性操作,但合在一起就不是原子操作了。
  • 语句3和语句4不是原子性操作。因为包含三个操作:读取x的值,进行加1操作,写入新的值。

也就是说,只有简单的读取、赋值(而且必须是将字面量赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。

3.9 可见性

JMM规定通过变量修改后刷回主存,变量读取前从主内存读取来实现可见性,包括普通变量和volatile变量。

  • volatile
    volatile规定了即刻刷新,而普通变量没有这个规定。
  • synchronized
    对一个变量unlock之前,必须先通过store->write来把变量同步回主内存,以使得该变量的最新修改对其他线程可见。
  • final
    被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把this的引用传递出去,那在其他线程中就能看见final字段的值。

3.10 有序性

0x04 OOM

4.1 常见OOM

4.1.1 堆溢出

  • Exception in thread “main”: java.lang.OutOfMemoryError: Java heap space
    原因:对象不能被分配到堆内存中。此种情况最常见,一般由于内存泄露或者堆的大小设置不当引起。

内存泄露是指:本来不再会被使用的对象的内存不能被回收。典型的例子如下:

  1. 一个没有实现hasCode和equals方法的Key类在HashMap中重复保存
  2. 各种提供了close()方法的对象
    比如数据库连接(dataSourse.getConnection()),网络连接(socket)和io连接,以及使用其他框架的时候,
    除非其显式的调用了其close()方法(或类似方法)将其连接关闭,否则是不会自动被GC回收的。根本原因是长生命周期对象持有短生命周期对象的引用。
  3. 单例模式导致的内存泄露
    单例模式,很多时候我们可以把它的生命周期与整个程序的生命周期看做差不多的,所以是一个长生命周期的对象。如果这个对象持有其他对象的引用,也很容易发生内存泄露。
  4. 内部类和外部模块的引用
    其实原理依然是一样的,只是出现的方式不一样而已。
  5. 容器对对象的引用
    就算将对象的引用指向为null,容器依然会存有指向这个对象的引用
  6. 可参考以下文章,排查内存泄露经验:
    小心踩雷,一次Java内存泄漏排查实战

4.1.2 栈溢出

  • Exception in thread “main”: java.lang.StackOverflowError
    原因:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;
  • Exception in thread “main”: java.lang.OutOfMemoryError
    原因:如果虚拟机栈可以动态扩展(大部分虚拟机支持),当扩展时无法申请到足够的内存时会抛出异常。

4.1.3 方法区和运行时常量池溢出

  • JDK1.8以前:
    • Exception in thread “main”: java.lang.OutOfMemoryError: PermGen space
      原因:类或者方法不能被加载到永久代。它可能出现在一个程序加载很多类的时候,比如引用了很多第三方的库;还可能是CGLib类增强将动态生成类加入方法区内存,或动态语言如Groovy,还有JSP文件首次运行也会编译为Java类。
  • JDK1.8以后的常量池溢出:
    • Exception in thread “main” java.lang.OutOfMemoryError: Java heap space
      原因:因为JDK1.8以后常量池移入了Java Heap。

4.1.4 其他溢出

  • Exception in thread “main”: java.lang.OutOfMemoryError: Requested array size exceeds VM limit
    原因:创建的数组大于堆内存的空间
  • Exception in thread “main”: java.lang.OutOfMemoryError: request bytes for . Out of swap space?
    原因:分配本地分配失败。JNI、本地库或者Java虚拟机都会从本地堆中分配内存空间。
  • Exception in thread “main”: java.lang.OutOfMemoryError: (Native method)
    原因:此错误表示问题源自native调用而不是JVM。
  • Exception in thread “main” java.lang.OutOfMemoryError: GC overhead limit exceeded
    原因:这个错误会出现在这个场景中:GC占用了多余98%(默认值)的CPU时间,却只回收了少于2%(默认值)的堆空间。目的是为了让应用终止,给开发者机会去诊断问题。一般是应用程序在有限的内存上创建了大量的临时对象或者弱引用对象,从而导致该异常。虽然加大内存可以暂时解决这个问题,但是还是强烈建议去优化代码,后者更加有效。JVM参数:-XX:-UseGCOverheadLimit 禁用这个检查,其实这个参数解决不了内存问题,只是把错误的信息延后,替换成 java.lang.OutOfMemoryError: Java heap space

4.2 处理方法

运行前JVM参数加入 -XX:+HeapDumpOnOutOfMemoryError将异常现场输出内存堆转储快照dump文件。

出现OOM后,需要分析Dump文件,确认是内存泄露还是内存溢出。

可以使用Eclipse Memory Analyzer对dump文件做分析,重点是检查溢出的对象是否必要(即确认是内存泄露还是溢出)

JetBrains JVM Debugger Memory View plugin

4.2.1 内存泄露

可通过工具看到GC Roots引用链,从而找到泄露对象是通过怎样的路径与GC Roots相关联,并导致GC无法自动回收的,从而定位到泄露代码位置。

4.2.2 内存溢出

  • 检查堆参数,即-Xmx和-Xms,看机器内存是否还能调大这些参数
  • 检查代码,对象生命或持有周期是否并不需要那么长

0x05 JVM内存调参

在通过一张图来了解如何通过参数来控制各区域的内存大小:

JVM调参

-Xms设置堆的最小空间大小。
-Xmx设置堆的最大空间大小。
-Xmn:设置年轻代大小
-XX:NewSize设置新生代最小空间大小。
-XX:MaxNewSize设置新生代最大空间大小。
-XX:PermSize设置永久代最小空间大小。
-XX:MaxPermSize设置永久代最大空间大小。
-Xss设置每个线程的堆栈大小
-XX:+UseParallelGC:选择垃圾收集器为并行收集器。此配置仅对年轻代有效。即上述配置下,年轻代使用并发收集,而年老代仍旧使用串行收集。
-XX:ParallelGCThreads=20:配置并行收集器的线程数,即:同时多少个线程一起进行垃圾回收。此值最好配置与处理器数目相等。

注意:没有直接设置老年代的参数,但是可以设置堆空间大小和新生代空间大小两个参数来间接控制:

老年代空间大小=堆空间大小-年轻代大空间大小

典型JVM参数配置参考:

java-Xmx3550m-Xms3550m-Xmn2g-Xss128k
-XX:ParallelGCThreads=20
-XX:+UseConcMarkSweepGC-XX:+UseParNewGC
-Xmx3550m:设置JVM最大可用内存为3550M。

其中:
-Xms3550m:设置JVM促使内存为3550m。此值可以设置与-Xmx相同,以避免每次垃圾回收完成后JVM重新分配内存。

-Xmn2g:设置年轻代大小为2G。整个堆大小=年轻代大小+年老代大小+持久代大小。持久代一般固定大小为64m,所以增大年轻代后,将会减小年老代大小。此值对系统性能影响较大,官方推荐配置为整个堆的3/8。

-Xss128k:设置每个线程的堆栈大小。JDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K。更具应用的线程所需内存大 小进行调整。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000 左右。

0x06 JVM常用概念

6.1 字面量和符号引用

  • 字面量
    字面量就是我们所说的常量概念,如文本字符串、被声明为final的常量值等。

  • 符号引用
    符号引用是一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可(它与直接引用区分一下,直接引用一般是指向方法区的本地指针,相对偏移量或是一个能间接定位到目标的句柄)。符号引用一般包括下面三类常量:

    • 类和接口的全限定名
    • 字段的名称和描述符
    • 方法的名称和描述符
  • JVM为每个已加载的类都维护一个.class文件常量池和运行时常量池,其中.class文件常量池存放编译期生成的各种用到的字面量(包括实际的常量(string, integer, 和floating point常量))和符号引用(field变量和方法)。

  • 常量池中的数据项像数组项一样,是通过索引访问的。

  • 比如,String a = "hello" ,则hello为字面量

  • Object = c中,因为这里引用了非字面量的c,所以不管c是变量还是常量,都是一个字符串定义的符号,称为符号引用。在编译阶段,无法确定具体的内存地址,所以会使用符号来代替,即对抽象的类或接口进行符号填充为符号表,存在该类的.class文件的常量池内。

    当该类加载时第一次遇到该符号引用时,会在连接的解析阶段将符号引用转为直接引用。具体来说,直接引用是指向目标的指针、相对偏移量或是间接定位到目标的句柄。他存储于运行时常量池,这是相当于将常量标识为已解析,以后不用再重复解析。

6.2 常量池

6.2.1 全局字符串常量池(string pool也有叫做string literal pool)

全局字符串池里的内容是在类加载完成,经过验证,准备阶段之后在堆中生成字符串对象实例,然后将该字符串对象实例的引用值存到string pool中(注意:string pool中存的是引用值而不是具体的实例对象,具体的实例对象是在堆中开辟的一块空间存放的。)。

  • 只存字符串对象的引用
    在HotSpot JVM里实现的string pool功能的是一个StringTable类,它是一个Hashtable,里面存的是驻留字符串(也就是我们常说的用双引号括起来的)的引用(而不是驻留字符串实例本身),也就是说在堆中的某些字符串实例被这个StringTable引用之后就等同被赋予了驻留字符串的身份。该StringTable在每个HotSpot JVM的实例只有一份,被所有的类共享。

  • String的intern方法:
    如果字符串常量池中已经有了这个字符串,那么直接返回常量池中的它的引用,如果没有,那就将它的引用保存一份到字符串常量池,然后直接返回这个引用。

  • lazy特性-字面量进入字符串常量池的时机:
    全局字符串常量池是惰性的。

    就HotSpot VM的实现来说,加载类的时候,那些字符串字面量会进入当前类的运行时常量池,不会进入全局字符串常量池(即在字符串常量池中没有相应的引用,在堆中也没有生成对应的对象)。加载类的时,没有解析字符串字面量,等到执行ldc指令的时候就会触发这个解析的动作。ldc指令的语义是:到当前类的运行时常量池区查找该index对应的项,如果该项没有解析就解析,并返回解析后的内容。在遇到String类型常量时,解析的过程是如果发现字符串常量池中已经有了内容匹配的String类型的引用,就直接返回这个引用,如果没有内容匹配的String实例的引用,就会在Java堆中创建一个对应内容的String对象,然后在字符串常量池中记录下这个引用。

    说明:自己的一点理解,上面说的时对字符串的解析,其实对方法解析也是类似,有些方法也是lazy resolve,有一部分符号引用是在类加载阶段或者第一次使用的时候就转化为直接引用,被称为静态解析(例如静态方法、私有方法等非虚方法),另一部分将在每一次运行期间转换为直接引用,被称为动态连接(例如静态分派),这部分也是lazy resolve。

JDK1.8后,字符串常量池移入了堆中。

例子:

class Test{
	public static String s1 = "static";
	public static void main(String[] args) {
		String s2 = new String("he")+new String("llo");
		s2.intern();
		String s3 = "hello";
		System.out.println(s2==s3);  //true
	}
}
  1. 类加载
    “static” “he” “llo” "hello"都会进入Class常量池,但类加载阶段由于解析阶段是lazy的,所以不会创建实例,更不会驻留字符串常量池。

    但要注意这个“static"和其他三个不一样,它是静态的,在加载阶段的初始化阶段,会为静态遍历执行初始值,也就是将"static"赋值给s1,所以会创建"static"字符串对象, 并且会保存一个指向它的引用到字符串常量池。

  2. 运行阶段
    运行main方法后,执行String s2 = new String("he")+new String("llo")语句,创建"he"和"llo"的对象,并会保存引用到字符串常量池中,然后内部创建一个StringBuilder对象,一路append,最后调用toString()方法得到一个String对象(内容是hello,注意这个toString方法会new一个String对象),并把该String对象赋值给s2(注意这里没有把hello的引用放入字符串常量池,而只是在堆中,引用在s2)。

  3. s2.intern()
    此时字符串常量池中没有,它会将上面的这个"hello"对象的引用保存到字符串常量池,然后返回这个对象的引用,但是这个返回的引用没有变量区接收,所以没用。

    注意此时已经在字符串常量池有了"hello"对象的引用了,就是s2的指向的那个对象。

  4. String s3 = “hello”
    因为字符串常量池中已经有了,所以直接指向堆中"hello"对象,所以s2和s3引用相同

  5. 示意图如下:
    字符串常量池例子

关于字符串常量池,可以看更多文章

6.2.2 .class文件常量池(class constant pool)

我们都知道,class文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(constant pool table),用于存放编译器生成的各种字面量(Literal)和符号引用(Symbolic References)。

.class文件常量池的每一项常量都是一个表,一共有如下表所示的11种各不相同的表结构数据,这每个表开始的第一位都是一个字节的标志位(取值1-12),代表当前这个常量属于哪种常量类型。
 常量池的项目类型
每种不同类型的常量类型具有不同的结构,具体的结构本文就先不叙述了,本文着重区分这三个常量池的概念(读者若想深入了解每种常量类型的数据结构可以查看《深入理解java虚拟机》第六章的内容)。

6.2.3 运行时常量池(runtime constant pool)

运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池(Constant Pool Table),存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池。

运行时常量池具备动态性,也就是并非预置入Class文件的内容才能进入方法区的运行时常量池,运行期间也可能将新的常量放入池中。

当java文件被编译成class文件之后,也就是会生成我上面所说的class常量池,那么运行时常量池又是什么时候产生的呢?

jvm在执行某个类的时候,必须经过加载、连接、初始化,而连接又包括验证、准备、解析三个阶段。而当类加载到内存中后,jvm就会将class常量池中的内容存放到运行时常量池中,由此可知,运行时常量池也是每个类都有一个。在上面我也说了,class常量池中存的是字面量和符号引用,也就是说他们存的并不是对象的实例,而是对象的符号引用值。而经过解析(resolve)之后,也就是把符号引用替换为直接引用,解析的过程会去查询全局字符串池,也就是我们上面所说的StringTable,以保证运行时常量池所引用的字符串与全局字符串池中所引用的是一致的。

Java代码在进行编译时,并不像C那样有"连接"这一步骤,而是在虚拟机加载Class文件的时候进行动态连接。也就是说,Class文件不会保存各个方法、字段的最终内存布局信息,因此这些字段、方法的符号引用不经过运行期间 转换的话无法得到真正的内存入口地址,也就无法直接被虚拟机使用。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。

举个实例来说明一下:

String str1 = "abc"; 
String str2 = new String("def"); 
String str3 = "abc"; 
String str4 = str2.intern(); 
String str5 = "def"; 
System.out.println(str1 == str3);//true 
System.out.println(str2 == str4);//false 
System.out.println(str4 == str5);//true

上面程序执行步骤如下:

  1. 经过编译之后,在该类的.class常量池中存放一些符号引用
  2. 类加载之后,将.class常量池中存放的符号引用转存到运行时常量池中
  3. 经过验证,准备阶段之后,在堆中生成驻留字符串的实例对象(也就是上例中str1所指向的”abc”实例对象),
  4. 然后将这个对象”abc”的引用存到全局String Pool中,也就是StringTable中,
  5. 最后在解析阶段,要把运行时常量池中的符号引用替换成直接引用,那么就直接查询StringTable,保证StringTable里的引用值与运行时常量池中的引用值一致。

回到上面的那个程序,现在就很容易解释整个程序的内存分配过程了:

  1. 首先,在堆中会有一个”abc”实例,全局StringTable中存放着”abc”的一个引用值,
  2. 然后在运行第二句的时候会生成两个实例,一个是”def”的字符串实例对象,并且StringTable中存储一个”def”的引用值;还有一个是new出来的一个”def”的实例对象,与前面那个是不同的实例。
  3. 当在解析str3的时候查找StringTable,里面有”abc”的全局驻留字符串引用,所以str3的引用地址与之前的那个已存在的相同。
  4. str4是在运行的时候调用intern()函数,返回StringTable中”def”的引用值,如果没有就将str2的引用值添加进去,在这里,StringTable中已经有了”def”的引用值了,所以返回上面在new str2的时候添加到StringTable中的 “def”引用值,而不是str2创建的那个对象,所以是false。
  5. 最后str5在解析的时候就也是指向存在于StringTable中的”def”的引用值,所以是true。

6.2.4 总结

  1. 全局常量池在每个JVM中只有一份,存放的是字符串常量的引用值。
  2. .class文件常量池是在编译的时候每个class都有的,在编译阶段,存放的是常量的符号引用。
  3. 运行时常量池是在类加载完成之后,将每个class常量池中的符号引用解析为直接引用后转存到运行时常量池中,也就是说,每个class都有一个运行时常量池,类在解析之后,将符号引用替换成直接引用,与全局常量池中的引用值保持一致。

0xFF 参考文档

JVM内存结构(基于JDK8)

RUNTIME DATA AREAS – JAVA’S MEMORY MODEL
这篇文章是jvm系列(二):JVM内存结构的英文原文

深入理解java方法调用时的参数传递

Class实例在堆中还是方法区中?

《深入理解Java虚拟机-周志明》

深入理解Java类型信息(Class对象)与反射机制

JVM内存结构 VS Java内存模型 VS Java对象模型

猜你喜欢

转载自blog.csdn.net/baichoufei90/article/details/87348907
今日推荐