JVM 简介—1.Java 内存区域
1.运行时数据区的介绍
(1)运行时数据区的定义
Java 虚拟机在执行 Java 程序的过程中,会把它所管理的内存划分为若干个不同的数据区域,这些区域各有各的用途以及各自的创建和销毁时间也不一样。有的区域会随着虚拟机的进程启动而存在,有的区域则依赖用户线程的启动和结束而进而跟着建立和销毁。
(2)运行时数据区的类型
程序计数器、Java 虚拟机栈、本地方法栈、Java 堆、方法区(运行时常量池)、直接内存。
(3)运行时数据区的展示图
2.运行时数据区各区域的作用
(1)程序计数器
当前线程所执行的字节码的行号指示器,占用内存空间小,线程私有,各线程间独立存储,互不影响。
字节码解释器工作时:就是通过改变程序计数器的值,来选取下一条需要执行的字节码指令。
程序计数器是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等功能都依赖它完成。
Java 多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何时刻,一个单核处理器都只会执行一条线程中的指令。为了使得线程切换后能恢复到正确的执行位置,因此每条线程都需要一个独立的程序计数器。
如果线程正在执行的是一个 Java 方法,程序计数器记录的是正在执行的虚拟机字节码指令的地址。
如果线程正在执行的是本地(Native)方法,程序计数器记录的值则应为空(Undefined)。
Java 虚拟机规范中程序计数器不会出现 OOM 情况。
(2)Java 虚拟机栈
Java 虚拟机栈是线程私有的,它的生命周期和线程同生共死。
一.Java 方法执行的线程内存模型
每个方法在开始执行时,Java 虚拟机会同步创建一个栈帧用于存储:局部变量表、操作数栈、动态链接、方法出口等信息。每个方法的执行,对应着栈帧在虚拟机栈中入栈和出栈的过程。
二.局部变量表里面存放着各种基本数据类型和对象引用
基本数据类型:boolean、byte、char、short、int、float、long、double。对象引用:reference 类型,并非对象本身。reference 类型的对象引用可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄。
可用-Xss 进行虚拟机栈大小的设置:默认为 1M。
三.Java 虚拟机规范中虚拟机栈有两类异常状态
如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常。
如果 Java 虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存也会抛出 OutOfMemoryError 异常。
注意:HotSpot 虚拟机的栈容量是不能动态扩展的。
(3)本地方法栈
作用和 Java 栈一样,本地方法栈保存的是 native 方法的信息。当一个 JVM 创建的线程调用 native 方法后,JVM 不再为其创建栈帧。JVM 只会简单地动态链接并直接调用 native 方法,例如 Object 类的 wait 方法:
本地方法栈会在栈深度溢出或栈扩展失败时,分别抛出 StackOverflowError 和 OutOfMemoryError 异常。
(4)Java 堆
Java 中几乎所有对象实例都在堆上分配内存,因为部分对象由于逃逸分析、栈上分配、标量替换而不在堆上分配的。
Java 堆是 Java 开发者需要重点关注的一块区域,因为涉及到内存的分配(new 关键字、反射)与回收(回收算法、收集器)。
如果从分配内存的角度看,所有线程共享的 Java 堆中可以划分出多个线程私有的分配缓冲区(TLAB)。在 Java 堆中的这些线程私有的分配缓冲区(TLAB)可以提升对象分配效率,而将 Java 堆细分的目的就是为了更好地回收内存,或者更快地分配内存。
当前主流的 Java 虚拟机对 Java 堆都支持可动态扩展。如果在 Java 堆中没有内存完成实例分配,并且堆也无法再扩展时,Java 虚拟机将会抛出 OutOfMemoryError 异常。
Java 堆的相关参数含义:
-Xms:堆的最小值
-Xmx:堆的最大值
-Xmn:新生代的大小
-XX:NewSize:新生代最小值
-XX:MaxNewSize:新生代最大值
(5)方法区
一.方法区概述
方法区(如 HotSpot 虚拟机中的元空间或者永久代)。方法区和 Java 堆一样,是共享的区域,所有线程都可以共享这一区域。
方法区主要存放:类信息、常量、静态变量和 JIT 编译后的代码缓存等。在 JVM 启动的时候,方法区就被创建为固定大小或可动态扩容的区域。方法区在逻辑上属于堆的一部分,但一些简单的实现不会进行 GC 回收。因而方法区可看作是独立于 Java 堆的一块空间。
JDK1.7 已经把原本放在永久代的字符串常量池、静态变量等移出。JDK1.8 则完全放弃了永久代的概念,由在本地内存中实现的元空间代替,把 JDK1.7 中永久代还剩余的内容(主要是类型信息)全移到元数据空间里。
JDK1.7 及以前:
-XX:PermSize; -XX:MaxPermSize
JDK1.8 及以后:
-XX:MetaspaceSize; -XX:MaxMetaspaceSize
二.方法区的内部结构
三.栈、堆、方法区的交互
四.方法区的垃圾回收
方法区的垃圾回收主要包含两部分内容:废弃的常量、不再使用的类。判断一个常量是否废弃:没有任何地方引用该常量。
判断一个类是否不再使用的条件如下:
条件 1:该类所有的实例都已被回收,堆中不存在该类及其子类的实例。
条件 2:加载该类的类加载器已被回收,通常该条件很难达成。
条件 3:该类对应的 java.lang.class 对象没有在任何地方被引用(如反射)。
Java 虚拟机被允许对满足上述三个条件的无用类进行回收,但仅仅是被允许,Java 虚拟机规范中不要求在方法区实现垃圾回收。
方法区的无用类仅仅被允许回收,不像对象那样没有引用就必然会回收,可以使用-Xnoclassgc 参数控制是否要对类型进行回收。
在大量使用反射、动态代理、CGLib 等字节码框架,动态生成 JSP 以及 OSGi 这类频繁自定义类加载器的场景中,通常需要 JVM 具备卸载类的能力,保证不会对方法区造成过大内存压力。
(6)运行时常量池
运行时常量池是方法区的一部分,运行时常量池用于存放编译期生成的各种字面量和符号引用。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table)。
常量池表用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行常量池中。
3.各个版本内存区域的变化
(1)JDK1.6 的内存区域
(2)JDK1.7 的内存区域
(3)JDK1.8 的内存区域
JDK1.8 的方法区在运行时数据区消失了,因为永久代(方法区)来存储类信息、常量、静态变量等数据不是个好主意,很容易遇到内存溢出的问题,而且对永久代(方法区)进行调优也很困难,所以 JDK1.8 将这些信息挪出来放到了元数据空间里。
同时 JDK1.8 将元数据空间与堆的垃圾回收进行了隔离,避免由于永久代(方法区)引发的 Full GC 和 OOM 等问题。
从理论上讲,在 JDK1.8 之后,这个元数据空间只受限于物理内存的大小,而不会再受限于整个虚拟机所管理的内存大小。
平时使用时,最好还是对元空间进行限制,否则会一直增长直到物理内存满了,导致服务器宕机。
4.直接内存的使用和作用
(1)通过 DirectByteBuffer 对象使用直接内存
注意:元数据空间不是在直接内存里,而是在本地内存里。直接内存通常在网络通讯的时候使用较多。
直接内存不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。
如果使用了 NIO,这块区域会被频繁使用。在 Java 堆内可以用 DirectByteBuffer 对象直接引用并操作直接内存。这块内存不受 Java 堆大小限制,但受本机总内存的限制。可以通过 MaxDirectMemorySize 来设置直接内存的大小,所以也会 OOM。在平时使用时,直接内存最好也进行限制。
(2)直接内存可以避免在 Java 堆和 Native 堆中来回复制数据
直接内存并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。
在 JDK1.4 中新加入了 NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,它可以使用 native 函数库直接分配直接内存(也可以说是堆外内存),然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象来操作其数据,这能在一些场景中提高性能,避免在 Java 堆和 Native 堆中来回复制数据。其中这个 DirectByteBuffer 对象其实可以看成是直接内存的引用。
5.站在线程的角度看 Java 内存区域
一个线程在一个时刻只能运行一个方法,只能运行一行代码,所以一个线程只需要一个本地方法栈,只需要一个程序计数器。
虚拟机栈、本地方法栈、程序计数器都是和线程同生共死的。Java 堆、方法区则是和 Java 进程同生共死的。GC 是不发生在栈上的,GC 只发生在堆和方法区上。一个线程调用本地方法的话,会开辟一个虚拟机栈和本地方法栈。
6.深入分析堆和栈的区别
(1)堆和栈在功能上的区别
栈内存会以栈帧的方式存储方法调用的过程和所使用的变量。方法调用过程中使用的变量包括:基本数据类型变量和对象的引用变量。其内存分配在栈上,里面的变量出了作用域就会自动释放。
堆内存用来存储 Java 中的对象。无论是成员变量、局部变量、还是类变量,这些变量指向的对象都是存储在堆内存的。
类的成员变量存放在堆,类的方法和静态变量存放在方法区。注意:JDK1.7 之后,字符串常量和静态变量移出了方法区到堆里去了。
(2)堆和栈是线程独享还是线程共享
栈内存归属于单个线程,每个线程都会有一个栈内存,其存储的变量只能在其所属线程中可见。
栈内存可理解成线程的私有内存,堆内存中的对象对所有线程可见,堆内存中的对象可被所有线程访问。
(3)堆和栈的空间大小对比
栈的内存要远远小于堆内存,栈的深度是有限制的,可能发生 StackOverFlowError 问题。
(4)区别堆和栈的示例代码
SimpleHeap 类的 main 方法运行时,堆、方法区、Java 栈如下图示:main 方法中的 s1 局部变量实际上指向的是 s1 实例对象的引用,s1 局部变量是在栈帧里,而 s1 实例对象是在堆上。所以可以理解为:对于"A a = new A();",a 在栈上,new A()在堆上。
(5)堆中创建出来的类的对象不包含类的成员方法
Java 堆是用来存放动态产生的数据,比如 new 出来的对象,注意创建出来的对象只包含属于各自的成员变量,并不包括成员方法。因为同一个类的对象拥有各自的成员变量,存储在堆中,但是它们共享该类的方法,并不是每创建一个对象就复制成员方法一次。
(6)多个线程时的内存区域描述
7.方法的出入栈和栈上分配、逃逸分析及 TLAB
(1)方法会打包成栈帧
一个栈帧至少要包含局部变量表、操作数栈和帧数据区。执行任何一个方法时,方法会打包成一个栈帧。下图展示的是一个方法打包成栈帧的流程:
(2)栈上分配
几乎所有的对象都是在堆上分配的,栈上分配就是虚拟机提供的一种优化技术。其基本思想是将线程私有的对象打散分配在栈上,而不分配在堆上。这样的好处是对象跟着方法调用自行销毁,不需要进行垃圾回收,从而提高性能。
如下 test 方法里定义了一个对象 u,其他线程是访问不到的。那么对于对象 u,就可以在栈上进行分配:
(3)逃逸分析
栈上分配需要的技术基础:逃逸分析。逃逸分析的目的是判断对象的作用域是否会逃逸出方法体。
注意:任何可以在多个线程间共享的对象,一定都属于逃逸对象。在 JDK1.8+,逃逸分析默认是开启的。一个线程频繁创建相同对象,就可以使用逃逸分析。
如下的 User 类型的对象 u 就逃逸出方法 test,这个 u 作为 test 方法的返回值传出去了,其他的线程或方法会使用。此时可称 u 逃出了 test 方法的作用域,这个 u 就不能使用栈上分配了。
(4)如何启用栈上分配
所以,对栈上分配发生影响的参数有三个:
关掉任何一个参数都不会发生栈上分配,由于逃逸分析和标量替换默认是打开的,所以 JVM 的参数只用-server 一样可以有栈上替换的效果。
(5)线程本地分配缓冲 TLAB
TLAB 全称是 ThreadLocalAllocBuffer,线程本地分配缓冲。创建对象是在堆上分配的,需要在堆上申请指定大小内存的。一个堆一块区域,线程 A 进来分配区域 a,线程 B 进来分配区域 b。
如果有大量线程同时申请堆上的内存,为避免两个线程申请内存时不会申请同一块内存,需要对申请进行加锁。加锁不仅在并发编程时会有,虚拟机在实现时同样要考虑并发而加锁;如果不加锁,就有可能两个线程同时分配到同一块内存,导致数据错乱。
我们经常会 new 一个对象出来,所以内存分配是一个非常频繁的动作。因此内存分配时也就需要频繁加锁,而频繁加锁就会影响性能。一旦加锁,这种动作就会变成串行的模式,对性能影响很大。
所以 TLAB 的作用就是:它会事先在堆里面为每个线程分配一块私有内存,在线程 A 中 new 出的对象只在线程 A 的私有内存上进行分配。
所以 TLAB 的好处就是:由于线程的堆内存事前分配好了,因此同时分配时就不存在竞争了。从而大大提高了分配的效率,当私有内存用完了再重新申请继续使用。不过要注意的是,重新申请堆内存的动作还是需要保证原子性的。
TLAB 涉及到线程私有,每个线程在 new 对象时会在私有内存上分配内存。尽管线程 A 在私有内存区域 a 位置拥有一块私有内存并在上面分配了对象,但是这些对象对所有线程都是可见并可用的。也就是说这些 A 线程的对象在分配的时候只能在 a 区域分配而已,B 线程、C 线程也是可以看见它们并使用它们的。
注意:堆中所有对象对所有线程理论上都是可见的。
(6)栈上分配的效果
同样的 User 的对象实例,分配 100000000 次。启用栈上分配,只需 7ms,不启用,需要 3S:
不启用栈上分配:
8.虚拟机中的对象创建步骤
Java 程序几乎无时无刻都有对象被创建出来,虚拟机碰到一个 new 关键字时是如何创建对象的呢?
步骤一:进行类加载
首先检查 new 指令的参数是否能在常量池中定位到一个类的符号引用,并检查该符号引用代表的类是否被加载、解析和初始化过。如果没有,则执行相应的类加载过程。
步骤二:为对象分配内存
类加载完成后,虚拟机就要为这个新生对象分配内存,也就是把一块确定大小的内存从 Java 堆中划分出来。
如果 Java 堆中的内存是绝对规整的,所有用过的内存都放一边,空闲的内存放另一边,并且中间放一个指针作为已用内存和空闲内存的分界点的指示器,分配内存时就把该指针向空闲空间那边移动一段与对象大小相等的距离,这种分配方式称为指针碰撞。
如果 Java 堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法进行指针碰撞了,此时虚拟机就必须要维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为空闲列表。
选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。当使用 Serial、ParNew 等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效;当使用 CMS 这种基于清除算法的收集器时,理论上采用空闲列表来分配内存,但实际为了分配更快也加入指针碰撞。
除如何划分可用空间外,还有另外一个需要考虑的问题是对象创建在虚拟机中是非常频繁的行为。即使仅仅修改一个指针所指向的位置,在并发情况下也不是线程安全的。可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况。
解决这个问题有两种方案:
一种是对分配内存空间的动作进行同步处理,实际上虚拟机采用 CAS+失败重试的方式保证更新操作的原子性。另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小块私有内存 TLAB。
开启使用 TLAB 本地线程分配缓冲(Thread Local Allocation Buffer)时,在线程初始化时会申请一块指定大小的内存,只给当前线程使用。这样每个线程都单独拥有一个 Buffer,如果要分配内存,就在自己的 Buffer 上分配,这样就不存在竞争的情况。当 Buffer 容量不够的时候,再重新从 Eden 区域申请一块内存继续使用。
TLAB 的目的是在为新对象分配内存内存时,让每个 Java 应用线程用自己专属的分配指针来分配内存,减少同步开销。TLAB 只是让每个线程拥有私有的分配指针,创建的对象还是线程共享的。当一个 TLAB 用完了(分配指针 top 撞上 end 了),那么就重新申请一个 TLAB。
步骤三:为分配的内存空间初始化零值
内存分配完成后,虚拟机就需要将分配到的内存空间都初始化为零值。这一步操作保证了对象的实例字段在代码中可以不赋初始值就直接使用,也就是程序能访问到这些字段的数据类型所对应的零值。
步骤四:设置对象的对象头
内存空间初始化零值后,虚拟机就要对对象进行必要的设置,需要在对象的对象头中设置这些内容:这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。
步骤五:对象初始化
设置完对象的对象头信息后,从虚拟机的视角来看,一个新的对象已产生。但从 Java 程序的视角来看,对象创建才刚刚开始,所有的字段都还为零值。所以,执行 new 指令之后会接着把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
如下是详细的对象创建流程图:
9.对象的内存布局
在 HotSpot 虚拟机中,对象在内存中存储的布局可以分为 3 块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
对象头的第一部分是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁等。
对象头的第二部分是类型指针,即对象指向它的类的元数据的指针,JVM 虚拟机可以通过这个指针确定这个对象是哪个类的实例。
对象头的第三部分是对齐填充,它仅仅起着占位符的作用。因为 HotSpot 的自动内存管理系统要求对象的大小必须是 8 字节的整数倍。当对象其他数据部分没有对齐时,就需要通过对齐填充来补全。
10.对象的访问定位
建立对象是为了使用对象,Java 程序需要通过栈上的 reference 数据来操作堆上的具体对象。目前主流的访问方式有两种:使用句柄和直接指针。
一.通过句柄访问对象
如果使用句柄访问,那么 Java 堆中将会划分出一块内存来作为句柄池。reference 中存储的就是对象的句柄地址,句柄中包含了对象实例数据与类型数据各自的具体地址信息。
二.通过直接指针访问对象
如果使用直接指针访问, reference 中存储的直接就是对象地址:
这两种对象访问方式各有优势。
使用句柄访问的最大好处就是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄的实例数据指针,reference 本身无需修改,而在垃圾收集时移动对象又是非常普遍的行为。
使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在 Java 中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。
对于 HotSpot 而言,它是使用直接指针访问方式进行对象访问的。
11.堆参数设置和内存溢出示例
使用 CGLib 技术操作字节码生成大量的动态类可能会导致永久代 OOM。Spring 和 Dubbo 会通过反射来生成类实例,刚开始启动不会永久代溢出。但运行时间久了,加载的类越多就越有可能造成永久代溢出。
(1)Java 堆溢出的例子
一.出现 java.lang.OutOfMemoryError: GC overhead limit exceeded
运行前配置参数:
一般是某个循环里可能性在不停地分配对象,但分配太多把堆撑爆了,如下代码所示:
二.出现 java.lang.OutOfMemoryError: Java heap space
一般是分配了巨型对象,如下代码所示:运行前配置参数 :-Xms5m -Xmx5m -XX:+PrintGC
(2)虚拟机栈和本地方法栈溢出的例子
需要不停调用方法才有可能出现虚拟机栈和本地方法栈溢出,比如出现无限递归的情况时,就可能会出现虚拟机栈溢出。一般的方法调用是很难出现的,如果出现了就要考虑是否有无限递归。
出现 java.lang.StackOverflowError 异常:
在上面 StackOverflowError 的例子中,栈的深度是 2239。假如 diGui()函数增加入参,-Xss 还是 256k,那么栈的深度变为 1598。这说明栈帧变大了,因为执行任何一个方法时,方法会打包成一个栈帧。栈帧的内容包括:局部变量表(包括方法的入参)、操作数栈、帧数据区。
虚拟机栈带给的启示:方法的执行因为要打包成栈桢,所以天生要比实现同样功能的循环慢。所以树的遍历算法中:递归和非递归(循环来实现)都有存在的意义。递归代码简洁,非递归代码复杂但速度快。
(3)直接内存溢出的例子
出现 java.lang.OutOfMemoryError: Direct buffer memory 异常,这种情况通常发生在 NIO 通讯上。
文章转载自:东阳马生架构
评论