概述
如果在大学里学过或者在工作中使用过 C 或者 C++ 的读者一定会发现这两门语言的内存管理机制与 Java 的不同。在使用 C 或者 C++ 编程时,程序员需要手动的去管理和维护内存,就是说需要手动的清除那些不需要的对象,否则就会出现内存泄漏与内存溢出的问题。
如果你使用 Java 语言去开发,你就会发现大多数情况下你不用去关心无用对象的回收与内存的管理,因为这一切 JVM 虚拟机已经帮我们做好了。了解 JVM 内存的各个区域将有助于我们深入了解它的管理机制,避免出现内存相关的问题和高效的解决问题。
引出问题
在 Java 编程时我们会用到许多不同类型的数据,比如临时变量、静态变量、对象、方法、类等等。 那么他们的存储方式有什么不同吗?或者说他们存在哪?
运行时数据区域
Java 虚拟机在执行 Java 程序过程中会把它所管理的内存分为若干个不同的数据区域,各自有各自的用途。
这其中堆和方法区是线程之间共享的,而栈和程序计数器是线程私有的。
-
程序计数器
线程私有的,可以看作是当前线程所执行字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。分支、循环、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
这时唯一一个没有规定任何 OOM 异常的区域。
-
虚拟机栈
虚拟机栈也是线程私有的,生命周期与线程相同。栈里面存储的是方法的
局部变量
、对象的引用
等等。在这片区域中,规定了两种异常情况,当线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常。当虚拟机栈动态扩展无法申请到足够的内存时会抛出 OOM 异常。
-
本地方法栈
和虚拟机栈的作用相同,只不过它是为 Native 方法服务。HotSpot 虚拟机直接将虚拟机栈和本地方法栈合二为一了。
-
堆
堆是 Java 虚拟机所管理内存中最大的一块。是所有线程共享的一块内存区域,在虚拟机启动时创建。这个区域唯一的作用就是存放
对象实例
,也就是 NEW 出来的对象。这个区域也是 Java 垃圾收集器的主要作用区域。当堆的大小再也无法扩展时,将会抛出 OOM 异常。
可以说,此内存区域唯一的作用就是存放对象实例,几乎所有的对象实例和数组都在这里分配内存。
Java 堆是垃圾收集管理的主要区域,因此也被称为 GC 堆。垃圾收集都采用分代垃圾回收算法,所以 Java 堆还可以细分:新声代(再细致一点分为 Eden,From Survivor,To Survivor)和老年代。进一步划分的目的是跟好地回收内存,或者更快地分配内存。
-
方法区
方法区也是线程共享的内存区域,用于存储已经被虚拟机加载的
类信息
、常量
、静态变量
等等。当方法区无法满足内存分配需求时,会抛出 OOM 异常。这个区域也被称为永久代。
补充
虽然上面的图里没有运行时常量池和直接内存,但是这两部分也是我们开发时经常接触的。所以给大家补充出来。
-
运行时常量池
运行时常量池是方法区的一部分,Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种
字面量
和符号引用
,这部分内容将在类加载后存放到方法区的运行时常量池中。也会抛出 OOM 异常。 -
直接内存
直接内存并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域,但是却是
NIO
操作时会直接使用的一块内存,虽然不受虚拟机参数限制,但是还是会受到本机总内存的限制,会抛出 OOM 异常。
这里有一个概念希望大家能够清除,堆中使用分代垃圾回收算法时的永久代表方法区,它并不在堆内存中,上面的图片将其放在一起是为了说明分代垃圾回收算法会作用在这几个区域。
JDK 1.8 的改变
对于方法区,它是线程共享的,主要用于存储类的信息,常量池,方法数据,方法代码等。我们称这个区域为永久代
。它也是 JVM 垃圾回收作用的区域。
大部分程序员应该都见过 java.lang.OutOfMemoryError:PermGen space 异常,这里的 PermGen space
其实指的就是方法区。由于方法区主要存储类的相关信息,所以对于动态生成类的情况比较容易出现永久代
的内存溢出,典型的场景是在 JSP 页面比较多的情况,容易出现永久代内存溢出。
在 JDK 1.8 中,HotSpot 虚拟机已经没有 PermGen space 方法区这个地方了,取而代之的是一个叫 Metaspace
(元空间)的东西。
元空间与方法区最大的区别是:元空间不再虚拟机中,而是使用本地内存。默认情况下,元空间的大小仅受本地内存限制。
常量区原本在方法区中,现在方法区被移除了,所以常量池被放倒了堆中。
这样做的好处是:
这样更改的好处:
- 字符串常量存在方法区中,容易出现性能问题和内存溢出。
- 类和方法的信息等比较难确定大小,因此对于方法区大小的指定比较困难,太小容易出现方法区溢出,太大容易导致堆的空间不足。
- 方法区的垃圾回收会带来不必要的复杂度,并且回收效率偏低(垃圾回收会在下一章给大家介绍)。
虚拟机对象揭秘
对象的创建过程,最好是能记住,并且能知道每一步在做什么。
-
类加载检查:虚拟机遇到一条 new 指令的时候,首先去检查这个指令的参数能否在常量池中定位这个类的符号饮用,检查这个类的符号引用所代表的类是否已被加载,解析,初始化过。如果没有,那必须先执行响应的类加载过程。简单来说,就是要看对象的类是否已经被加载过了。
-
分配内存:在类加载检查通过后,接下来虚拟机将会为新生对象分配内存。对象所需的内存大小在类加载完毕后便可以确定了,为对象分配空间的任务相当于把一块确定大小的内存从 Java 堆中划分出来。
分配方式有指针碰撞和空闲列表两种。选择那种方式由 Java 堆是否规整决定,而 Java 堆是否规整由垃圾收集器是否带有压缩功能决定(复制算法和标记整理算法是规整的,标记清除算法是不规整的)。
内存分配并发问题
- CAS 失败重试,CAS 是客观锁的一种实现方式。
- TLAB:为每一个线程预先在 Eden 分配一块内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,如果不够,使用 CAS 进行分配。
-
初始化零值:内存分配完毕后,虚拟机将要分配的内存空间都初始化为零值(不包括对象头)。这一步保证了对象实例在 Java 中不赋初值就可以直接使用。
-
设置对象头:初始化零值完成之后,虚拟机要对对象进行必要的设置。比如对象的哈希码,对象的 GC 分代年龄信息,偏向锁,这些信息放在对象头中。
-
执行 init 方法:上面工作完成后,从虚拟机视角看,一个新的对象已经产生了。然后执行 init 方法,按照程序员的意愿将对象进行初始化。
对象构成
HotSpot 虚拟机中,对象在内存中的布局可以分为三块区域:对象头,实例数据和对齐填充。
对象头中包含两部分信息,第一部分用于存储对象自身运行时数据(哈希码,GC 分代年龄,锁状态标志),另一部分是类型指针,即指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
实例数据部分存储的对象的有效信息。
对其填充起到的是占位的作用。
对象的访问定位
- 句柄。
- 直接指针。
补充
String str1 = "abcd";
String str2 = new String("abcd");
System.out.println(str1==str2);//false
这两种方式创建的对象是有差别的,第一种方式是在常量池中,第二种方式是在堆内存中。
-
直接使用双引号声明创建出来的 String 对象会直接存储在常量池中。
-
如果不是使用常量池声明的 String 对象,可以使用 String 提供的 intern 方String.intern() 是一个 Native 方法,它的作用是:如果运行时常量池中已经包含一个等于此 String 对象内容的字符串,则返回常量池中该字符串的引用;如果没有,则在常量池中创建与此 String 内容相同的字符串,并返回常量池中创建的字符串的引用。
String s1 = new String("计算机"); String s2 = s1.intern(); String s3 = "计算机"; System.out.println(s2);//计算机 System.out.println(s1 == s2);//false,因为一个是堆内存中的String对象一个是常量池中的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
String s1 = new String("abc"); 这句话创建了几个对象?
先有字符串 “abc” 放入常量池,然后 new 了一个字符串 “abc” 放入 Java 堆。栈中的引用指向堆中的对象。
Java 基本类型的包装类的大部分都实现了常量池技术,即 Byte,Short,Integer,Long,Character,Boolean。除了 Boolean 之外的 5 种包装类都默认创建了 【-128 127】的缓存数据,超出此范围仍然去创建新的对象。Float 和 Double 并没有实现常量池技术。
Integer i1 = 33;
Integer i2 = 33;
System.out.println(i1 == i2);// 输出true
Integer i11 = 333;
Integer i22 = 333;
System.out.println(i11 == i22);// 输出false
Double i3 = 1.2;
Double i4 = 1.2;
System.out.println(i3 == i4);// 输出false
- Integer i1=40;Java 在编译的时候会直接将代码封装成Integer i1=Integer.valueOf(40);,从而使用常量池中的对象。
- Integer i1 = new Integer(40);这种情况下会创建新的对象。
Integer i1 = 40;
Integer i2 = new Integer(40);
System.out.println(i1==i2);//输出false