1.Java内存区域
1.1 概述
对于 Java 程序员来说,在虚拟机自动内存管理机制下,不再需要像 C/C++程序开发程序员这样为每一个 new 操作去写对应的 delete/free 操作,不容易出现内存泄漏和内存溢出问题。正是因为 Java 程序员把内存控制权利交给 Java 虚拟机,一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那么排查错误将会是一个非常艰巨的任务。
1.2 运行时数据区域
Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域。
JDK 1.8 之前:堆、方法区、虚拟机栈、本地方法栈、程序计数器。
JDK1.8:堆、元空间、虚拟机栈、本地方法栈、程序计数器。
线程私有:(为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储):
- 虚拟机栈
- 程序计数器
- 本地方法栈
线程共享:
- 堆
- 方法区
- 直接内存(非运行时数据区的一部分)
1.2.1 Java虚拟机栈
线程私有,生命周期和线程一致。为虚拟机执行 Java 方法 (也就是字节码)服务,每次方法调用的数据都是通过栈传递的。
Java 内存可以粗糙的区分为堆内存(Heap)和栈内存 (Stack),其中栈就是现在说的虚拟机栈,或者说是虚拟机栈中局部变量表部分。 (实际上,Java 虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。)
局部变量表:存放了编译期可知的各种基本类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,可能是一个指向对象起始地址的引
用指针)和 returnAddress 类型(指向了一条字节码指令的地址)
常见错误:
StackOverflowError:线程请求的栈深度大于虚拟机所允许的深度。
OutOfMemoryError:如果虚拟机栈可以动态扩展,而扩展时无法申请到足够的内存。
1.2.2 本地方法栈
和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机执行Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。
方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和
OutOfMemoryError 两种错误。
1.2.3 程序计数器
内存空间小,线程私有。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行指令的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器完成
如果线程正在执行一个 Java 方法,计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,计数器的值为 (Undefined)。
注意:程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
1.2.4 堆
JVM 所管理的内存中最大的一块。线程共享,主要是存放对象实例和数组。
什么情况对象可以直接在栈上分配?
Java中“几乎”所有的对象都在堆中分配,从jdk 1.7开始默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。
Java 堆是垃圾收集器管理的主要区域,因此也被称作GC 堆(Garbage Collected Heap) 所以 Java 堆还可以细分为:新生代和老年代:再细致一点有:Eden空间、From Survivor、To Survivor 空间和老年代。进一步划分的目的是更好地回收内存,或者更快地分配内存。
在 JDK 7 版本及JDK 7 版本之前,堆内存被通常被分为下面三部分:
- 新生代内存(Young Generation)
- 老生代(Old Generation)
- 永生代(Permanent Generation)
JDK1.8 版本之后方法区(HotSpot 的永久代)被彻底移除了,取而代之是元空间,元空间使用的是直接内存。
上图所示的 Eden 区、两个 Survivor 区都属于新生代(为了区分,这两个 Survivor 区域按照顺序被命名为 from 和 to),中间一层属于老年代。
大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入s0 或者 s1,并且对象的年龄还会加 1,当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold来设置。
堆这里最容易出现的就是 OutOfMemoryError 错误,常见的表现形式比如:
- OutOfMemoryError: GC Overhead Limit Exceeded : 当JVM花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。
- java.lang.OutOfMemoryError: Java heap space :假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发 java.lang.OutOfMemoryError: Java heap space 错误。(和本机物理内存无关,和配置的内存大小有关)。
1.2.5 方法区
线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。方法区也被称为永久代
JDK 1.8 之前永久代还没被彻底移除的时候通常通过下面这些参数来调节方法区大小
-XX:PermSize=N //方法区 (永久代) 初始大小
-XX:MaxPermSize=N //方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError:PermGen
JDK1.8 版本之后方法区(HotSpot 的永久代)被彻底移除了,取而代之是元空间,元空间使用的是直接内存。
-XX:MetaspaceSize=N //设置 Metaspace 的初始值
-XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小
为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace)?
- 永久代有一个 JVM 本身设置的固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。
- 元空间里面存放的是类的元数据,加载多少类由系统的实际可用空间来控制,这样能加载的类就更多了。
- 在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 没有永久代。
1.2.6 运行时常量池
属于方法区一部分,用于存放编译期生成的各种字面量和符号引用。编译器和运行期(String 的 intern() )都可以将常量放入池中。内存有限,无法申请内存时抛出OutOfMemoryError。
1.2.7 直接内存
非虚拟机运行时数据区的部分
在 JDK 1.4 中新加入 NIO (New Input/Output) 类,引入了一种基于**通道(Channel)和缓存区(Buffer)**的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。可以避免在 Java 堆和 Native 堆中来回复制数据的耗时操作。
OutOfMemoryError:会受到本机内存限制,如果内存区域总和大于物理内存限制从而导致动态扩展时出现该异常。
1.3 HotSpot 虚拟机
包含Java堆中对象的创建、布局和访问的全过程。
1.3.1 对象的创建
- 类加载检查
遇到 new 指令时,检查这个指令的参数能否在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果没有,执行相应的类加载。 - 分配内存
类加载检查通过之后,虚拟机为新对象分配内存(内存大小在类加载完成后便可确认)。在堆的空闲内存中划分一块区域(‘指针碰撞-内存规整’或‘空闲列表-内存交错’的分配方式)。 - 初始化零值
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例对象在 Java 代码中可以不赋初始值就直接使用。 - 设置对象头
把对象是那个类的实例、类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息存入对象头。 - 执行 init 方法
执行 new 指令后执行 init 方法才算一个真正可用的对象。
1.3.2 对象的内存布局
在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头、实例数据和对齐填充。
对象头(Header):包含两部分信息
- 用于存储对象自身的运行时数据(哈希码、GC 分代年龄、锁状态标志等等)
- 类型指针:虚拟机通过这个指针来确定这个对象是那个类的
实例数据(Instance Data):程序代码中所定义的各种类型的字段内容。
对齐填充(Padding):仅仅起占位作用。
1.3.3 对象的访问定位
使用对象时,Java程序通过栈上的 reference 数据来操作堆上的具体对象。
句柄访问:Java 堆中会划分出一块内存来作为句柄池,reference 中存储
的就是对象的句柄地址,句柄中包含了对象实例数据与类型数据各自的具体地址信息。
直接指针访问:reference 中直接存储对象地址
- 使用句柄的最大好处是 reference 中存储的是稳定的句柄地址,在对象移动时只改变实例数据指针,reference自身不需要修改。(对象频繁 GC )
- 直接指针访问的最大好处是速度快,节省了一次指针定位的时间开销。(对象频繁访问)
1.4 重点补充内容
1.4.1 String 类和常量池
String str1 = "abcd";/*先检查字符串常量池中有没有"abcd",如果字符串常量池中没有,
则创建一个,然后 str1 指向字符串常量池中的对象,如果有,则直接将 str1 指向"abcd"";*/
String str2 = new String("abcd");//堆中创建一个新的对象
String str3 = new String("abcd");//堆中创建一个新的对象 System.out.println(str1==str2);//false
System.out.println(str2==str3);//false
这两种不同的创建方法是有差别的。
- 第一种方式是在常量池中拿对象;
- 第二种方式是直接在堆内存空间创建一个新的对象。
只要使用 new 方法,便需要创建新的对象。
String 类型的常量池比较特殊。它的主要使用方法有两种:
- 使用双引号声明出来的 String 对象会直接存储在常量池中。
- 使用String 的 intern 方法。String.intern() 是一
个 Native 方法,它的作用是:运行时如果常量池中有此 String 对象内容的字符串,则返回常量池中该字符串的引用;如果没有,JDK1.7之前(不包含1.7)会在常量池中创建内容相同的字符串,并返回常量池中创建的字符串的引用,JDK1.7以及之后在常量池中记录此字符串的引用,并返回该引用。
String s1 = new String("计算机");
String s2 = s1.intern();
String s3 = "计算机";
System.out.println(s2);//计算机
System.out.println(s1 == s2);//false,一个是堆内存中 一个是常量池中的 String 对象,
System.out.println(s3 == s2);//true,因为两个都是常量池中的 String 对象
字符串拼接
String str1 = "str";
String str2 = "ing";
String str3 = "str" + "ing";//常量池中的对象
String str4 = str1 + str2; //在堆上创建的新的对象
String str5 = "string";//常量池中的对象
System.out.println(str3 == str4);//false
System.out.println(str3 == str5);//true
System.out.println(str4 == str5);//false
尽量避免多个字符串拼接,因为这样会重新创建对象。如果需要改变字符串的话,可以使用StringBuilder 或者 StringBuffer
1.4.2 String s1 = new String(“abc”);这句话创建了几个字符串对象?
如果池中已存在字符串常量“abc”,则只会在堆空间创建1个字符串常量“abc”。如果池中没有字符串常量“abc”,那么它将首先在池中创建,然后在堆空间中创建,因此将创建2个字符串对象。
参考
本文对JavaJVM进行回顾并整理,方便以后自己复习。参考网上各大帖子,取其精华整合自己的理解而成。
Java虚拟机(JVM)你只要看这一篇就够了!