Java内存结构知识
- 线程私有:程序计数器、虚拟机栈、本地方法区
- 线程共享:堆、方法区, 堆外内存(Java7的永久代或JDK8的元空间、代码缓存)
线程私有
程序计数器
PC计数器和cpu那个类似,用来储存下一条指令。
虚拟机栈
作用:主管 Java 程序的运行,它保存方法的局部变量、部分结果,并参与方法的调用和返回。
特点:
- 栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器
- JVM 直接对虚拟机栈的操作只有两个:每个方法执行,伴随着入栈(进栈/压栈),方法执行结束出栈
- 栈不存在垃圾回收问题
运行原理
-
JVM 直接对 Java 栈的操作只有两个,对栈帧的压栈和出栈,遵循“先进后出/后进先出”原则
-
在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)
-
执行引擎运行的所有字节码指令只针对当前栈帧进行操作
-
如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,称为新的当前栈帧
-
不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧中引用另外一个线程的栈帧
-
如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧
-
Java 方法有两种返回函数的方式,一种是正常的函数返回,使用 return 指令,另一种是抛出异常,不管用哪种方式,都会导致栈帧被弹出
帧栈
局部变量表(Local Variables)
操作数栈(Operand Stack)(或称为表达式栈)
动态链接(Dynamic Linking):指向运行时常量池的方法引用
方法返回地址(Return Address):方法正常退出或异常退出的地址
局部变量表
用来储存方法的临时变量。主要保存基本数据类型和对象引用,线程私有不存在线程安全问题。
slot
局部变量表储存的基本单位,4位。
操作数栈
感觉就是用来解决运算的。主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
栈顶缓存
HotSpot JVM 设计者们提出了栈顶缓存技术,将栈顶元素全部缓存在物理 CPU 的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率
动态链接
每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。
在 Java 源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在 Class 文件的常量池中。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么**动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用
虚表
静态链接:当一个字节码文件被装载进 JVM 内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时。这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接
动态链接:如果被调用的方法在编译期无法被确定下来,也就是说,只能在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接
在面向对象编程中,会频繁的使用到动态分派,如果每次动态分派都要重新在类的方法元数据中搜索合适的目标有可能会影响到执行效率。为了提高性能,JVM 采用在类的方法区建立一个虚方法表(virtual method table),使用索引表来代替查找。非虚方法不会出现在表中。
每个类中都有一个虚方法表,表中存放着各个方法的实际入口。
虚方法表会在类加载的连接阶段被创建并开始初始化,类的变量初始值准备完成之后,JVM 会把该类的方法表也初始化完毕
本地方法栈
-
Java 虚拟机栈用于管理 Java 方法的调用,而本地方法栈用于管理本地方法的调用
-
本地方法栈也是线程私有的
-
允许线程固定或者可动态扩展的内存大小
堆
对于大多数应用,Java 堆是 Java 虚拟机管理的内存中最大的一块,被所有线程共享。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数据都在这里分配内存。
为了进行高效的垃圾回收,虚拟机把堆内存逻辑上划分成三块区域(分代的唯一理由就是优化 GC 性能):
- 新生带(年轻代):新对象和没达到一定年龄的对象都在新生代
- 老年代(养老区):被长时间使用的对象,老年代的内存空间应该要比年轻代更大
- 元空间(JDK1.8 之前叫永久代):像一些方法中的操作临时对象等,JDK1.8 之前是占用 JVM 内存,JDK1.8 之后直接使用物理内存
年龄
年轻代
年轻代是所有新对象创建的地方。当填充年轻代时,执行垃圾收集。这种垃圾收集称为 Minor GC。年轻一代被分为三个部分——伊甸园(Eden Memory)和两个幸存区(Survivor Memory,被称为from/to或s0/s1),默认比例是8:1:1
- 大多数新创建的对象都位于 Eden 内存空间中
- 当 Eden 空间被对象填充时,执行Minor GC,并将所有幸存者对象移动到一个幸存者空间中
- Minor GC 检查幸存者对象,并将它们移动到另一个幸存者空间。所以每次,一个幸存者空间总是空的
- 经过多次 GC 循环后存活下来的对象被移动到老年代。通常,这是通过设置年轻一代对象的年龄阈值来实现的,然后他们才有资格提升到老一代
老年代
旧的一代内存包含那些经过许多轮小型 GC 后仍然存活的对象。通常,垃圾收集是在老年代内存满时执行的。老年代垃圾收集称为 主GC(Major GC),通常需要更长的时间。
大对象直接进入老年代(大对象是指需要大量连续内存空间的对象)。这样做的目的是避免在 Eden 区和两个Survivor 区之间发生大量的内存拷贝
元空间
不管是 JDK8 之前的永久代,还是 JDK8 及以后的元空间,都可以看作是 Java 虚拟机规范中方法区的实现。
虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫 Non-Heap(非堆),目的应该是与 Java 堆区分开。
所以元空间放在后边的方法区再说
生命周期
在 JVM 内存模型的堆中,堆被划分为新生代和老年代
- 新生代又被进一步划分为 Eden区 和 Survivor区,Survivor 区由 From Survivor 和 To Survivor 组成
当创建一个对象时,对象会被优先分配到新生代的 Eden 区
- 此时 JVM 会给对象定义一个对象年轻计数器(
-XX:MaxTenuringThreshold
)
当 Eden 空间不足时,JVM 将执行新生代的垃圾回收(Minor GC)
- JVM 会把存活的对象转移到 Survivor 中,并且对象年龄 +1
- 对象在 Survivor 中同样也会经历 Minor GC,每经历一次 Minor GC,对象年龄都会+1
如果分配的对象超过了-XX:PetenureSizeThreshold
,对象会直接被分配到老年代
对象分配过程
为对象分配内存是一件非常严谨和复杂的任务,JVM 的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法和内存回收算法密切相关,所以还需要考虑 GC 执行完内存回收后是否会在内存空间中产生内存碎片。
- new 的对象先放在伊甸园区,此区有大小限制
- 当伊甸园的空间填满时,程序又需要创建对象,JVM 的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区
- 然后将伊甸园中的剩余对象移动到幸存者 0 区
- 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者 0 区,如果没有回收,就会放到幸存者 1 区
- 如果再次经历垃圾回收,此时会重新放回幸存者 0 区,接着再去幸存者 1 区
- 什么时候才会去养老区呢? 默认是 15 次回收标记
- 在养老区,相对悠闲。当养老区内存不足时,再次触发 Major GC,进行养老区的内存清理
- 若养老区执行了 Major GC 之后发现依然无法进行对象的保存,就会产生 OOM 异常
其他内存方面技巧
TLAB(Thread Local Allocation Buffer)
从内存模型而不是垃圾回收的角度,对 Eden 区域继续进行划分,JVM 为每个线程分配了一个私有缓存区域,它包含在 Eden 空间内
多线程同时分配内存时,使用 TLAB 可以避免一系列的非线程安全问题,同时还能提升内存分配的吞吐量,因此我们可以将这种内存分配方式称为快速分配策略
逃逸分析
逃逸分析(Escape Analysis)*是目前 Java 虚拟机中比较前沿的优化技术*。这是一种可以有效减少 Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot 编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
逃逸分析的基本行为就是分析对象动态作用域:
- 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
- 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中,称为方法逃逸。
使用逃逸分析,编译器可以对代码做优化:
- 栈上分配:将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配
- 同步省略:如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步
- 分离对象或标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而存储在 CPU 寄存器
JIT 编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无需进行垃圾回收了。
常见栈上分配的场景:成员变量赋值、方法返回值、实例引用传递
同步省略
线程同步的代价是相当高的,同步的后果是降低并发性和性能
在动态编译同步块的时候,JIT 编译器可以借助逃逸分析来判断同步块所使用的锁对象是否能够被一个线程访问而没有被发布到其他线程。如果没有,那么 JIT 编译器在编译这个同步块的时候就会取消对这个代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫做同步省略,也叫锁消除。
标量替换
标量(Scalar)是指一个无法再分解成更小的数据的数据。Java 中的原始数据类型就是标量。
相对的,那些的还可以分解的数据叫做聚合量(Aggregate),Java 中的对象就是聚合量,因为其还可以分解成其他聚合量和标量。
在 JIT 阶段,通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM 不会创建该对象,而会将该对象成员变量分解若干个被这个方法使用的成员变量所代替。这些代替的成员变量在栈帧或寄存器上分配空间。这个过程就是标量替换。
方法区
-
方法区(Method Area)与 Java 堆一样,是所有线程共享的内存区域。
-
虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫 Non-Heap(非堆),目的应该是与 Java 堆区分开。
-
运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本/字段/方法/接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将类在加载后进入方法区的运行时常量池中存放。运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的是
String.intern()
方法。受方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryErro
r 异常。 -
方法区的大小和堆空间一样,可以选择固定大小也可选择可扩展,方法区的大小决定了系统可以放多少个类,如果系统类太多,导致方法区溢出,虚拟机同样会抛出内存溢出错误
-
JVM 关闭后方法区即被释放
元空间和永久代
你是否也有看不同的参考资料,有的内存结构图有方法区,有的又是永久代,元数据区,一脸懵逼的时候?
- 方法区(method area)*只是 **JVM 规范**中定义的一个*概念,用于存储类信息、常量池、静态变量、JIT编译后的代码等数据,并没有规定如何去实现它,不同的厂商有不同的实现。而永久代(PermGen)**是 **Hotspot** 虚拟机特有的概念, Java8 的时候又被**元空间**取代了,永久代和元空间都可以理解为方法区的落地实现。
- 永久代物理是堆的一部分,和新生代,老年代地址是连续的(受垃圾回收器管理),而元空间存在于本地内存(我们常说的堆外内存,不受垃圾回收器管理),这样就不受 JVM 限制了,也比较难发生OOM(都会有溢出异常)
- Java7 中我们通过
-XX:PermSize
和-xx:MaxPermSize
来设置永久代参数,Java8 之后,随着永久代的取消,这些参数也就随之失效了,改为通过-XX:MetaspaceSize
和-XX:MaxMetaspaceSize
用来设置元空间参数 - 存储内容不同,元空间存储类的元信息,静态变量和常量池等并入堆中。相当于永久代的数据被分到了堆和元空间中
- 如果方法区域中的内存不能用于满足分配请求,则 Java 虚拟机抛出
OutOfMemoryError
- JVM 规范说方法区在逻辑上是堆的一部分,但目前实际上是与 Java 堆分开的(Non-Heap)
所以对于方法区,Java8 之后的变化:
- 移除了永久代(PermGen),替换为元空间(Metaspace);
- 永久代中的 class metadata 转移到了 native memory(本地内存,而不是虚拟机);
- 永久代中的 interned Strings 和 class static variables 转移到了 Java heap;
- 永久代参数 (PermSize MaxPermSize) -> 元空间参数(MetaspaceSize MaxMetaspaceSize
著作权归@pdai所有 原文链接:https://pdai.tech/md/java/jvm/java-jvm-struct.html
内部结构
方法区用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。
类型信息
对每个加载的类型(类 class、接口 interface、枚举 enum、注解 annotation),JVM 必须在方法区中存储以下类型信息
- 这个类型的完整有效名称(全名=包名.类名)
- 这个类型直接父类的完整有效名(对于 interface或是 java.lang.Object,都没有父类)
- 这个类型的修饰符(public,abstract,final 的某个子集)
- 这个类型直接接口的一个有序列表
域(Field)信息
- JVM 必须在方法区中保存类型的所有域的相关信息以及域的声明顺序
- 域的相关信息包括:域名称、域类型、域修饰符(public、private、protected、static、final、volatile、transient 的某个子集)
方法(Method)信息
JVM 必须保存所有方法的
- 方法名称
- 方法的返回类型
- 方法参数的数量和类型
- 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract 的一个子集)
- 方法的字符码(bytecodes)、操作数栈、局部变量表及大小(abstract 和 native 方法除外)
- 异常表(abstract 和 native 方法除外)
- 每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索
运行时常量池
常量池
一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息那就是常量池表(Constant Pool Table),包含各种字面量和对类型、域和方法的符号引用。
运行时常量池
在加载类和结构到虚拟机后,就会创建对应的运行时常量池
常量池表(Constant Pool Table)是 Class 文件的一部分,用于存储编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中
JVM 为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的
运行时常量池中包含各种不同的常量,包括编译器就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或字段引用。此时不再是常量池中的符号地址了,这里换为真实地址
- 运行时常量池,相对于 Class 文件常量池的另一个重要特征是:动态性,Java 语言并不要求常量一定只有编译期间才能产生,运行期间也可以将新的常量放入池中,String 类的
intern()
方法就是这样的
当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则 JVM 会抛出 OutOfMemoryError 异常
方法区垃圾回收
方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型。
先来说说方法区内常量池之中主要存放的两大类常量:字面量和符号引用。字面量比较接近 Java 语言层次的常量概念,如文本字符串、被声明为 final 的常量值等。而符号引用则属于编译原理方面的概念,包括下面三类常量:
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
HotSpot 虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收
判定一个类型是否属于“不再被使用的类”,需要同时满足三个条件:
- 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类及其任何派生子类的实例
- 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如 OSGi、JSP 的重加载等,否则通常很难达成
- 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
Java 虚拟机被允许堆满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,不使用了就必然会回收。是否对类进行回收,HotSpot 虚拟机提供了 -Xnoclassgc
参数进行控制,还可以使用 -verbose:class
以及 -XX:+TraceClassLoading
、-XX:+TraceClassUnLoading
查看类加载和卸载信息。
在大量使用反射、动态代理、CGLib 等 ByteCode 框架、动态生成 JSP 以及 OSGi 这类频繁自定义 ClassLoader 的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。
著作权归@pdai所有 原文链接:https://pdai.tech/md/java/jvm/java-jvm-struct.html
硬件
每个CPU基本上都包含一组在CPU内存中的寄存器。 CPU可以在这些寄存器上执行的操作比在主存储器中对变量执行的操作快得多。 这是因为CPU可以比访问主存储器更快地访问这些寄存器。
每个CPU还可以具有CPU高速缓存存储器层。 事实上,大多数现代CPU都有一些大小的缓存存储层。 CPU可以比主存储器更快地访问其高速缓存存储器,但通常不会像访问其内部寄存器那样快。 因此,CPU高速缓存存储器介于内部寄存器和主存储器的速度之间。 某些CPU可能有多个缓存层(级别1和级别2),但要了解Java内存模型如何与内存交互,这一点并不重要。 重要的是要知道CPU可以有某种缓存存储层。
计算机还包含主存储区(RAM)。 所有CPU都可以访问主内存。 主存储区通常比CPU的高速缓存存储器大得多。同时访问速度也就较慢.
通常,当CPU需要访问主存储器时,它会将部分主存储器读入其CPU缓存。 它甚至可以将部分缓存读入其内部寄存器,然后对其执行操作。 当CPU需要将结果写回主存储器时,它会将值从其内部寄存器刷新到高速缓冲存储器,并在某些时候将值刷新回主存储器。
可见性问题,原子性问题。