Java内存区域

Java 内存区域详解

若是没有特殊说明,都是针对的是 HotSpot 虚拟机。html

写在前面 (常见面试题)

基本问题

  • 介绍下 Java 内存区域(运行时数据区)
  • Java 对象的建立过程(五步,建议能默写出来而且要知道每一步虚拟机作了什么)
  • 对象的访问定位的两种方式(句柄和直接指针两种方式)

拓展问题

  • String 类和常量池
  • 8 种基本类型的包装类和常量池

一 概述

对于 Java 程序员来讲,在虚拟机自动内存管理机制下,再也不须要像 C/C++程序开发程序员这样为内一个 new 操做去写对应的 delete/free 操做,不容易出现内存泄漏和内存溢出问题。正是由于 Java 程序员把内存控制权利交给 Java 虚拟机,一旦出现内存泄漏和溢出方面的问题,若是不了解虚拟机是怎样使用内存的,那么排查错误将会是一个很是艰巨的任务。java

二 运行时数据区域

Java 虚拟机在执行 Java 程序的过程当中会把它管理的内存划分红若干个不一样的数据区域。JDK. 1.8 和以前的版本略有不一样,下面会介绍到。程序员

JDK 1.8 以前:web

JDK 1.8 :面试

线程私有的:算法

  • 程序计数器
  • 虚拟机栈
  • 本地方法栈

线程共享的:数组

  • 方法区
  • 直接内存 (非运行时数据区的一部分)

2.1 程序计数器

程序计数器是一块较小的内存空间,能够看做是当前线程所执行的字节码的行号指示器。字节码解释器工做时经过改变这个计数器的值来选取下一条须要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都须要依赖这个计数器来完。缓存

另外,为了线程切换后能恢复到正确的执行位置,每条线程都须要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,咱们称这类内存区域为“线程私有”的内存。安全

从上面的介绍中咱们知道程序计数器主要有两个做用:数据结构

  1. 字节码解释器经过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  2. 在多线程的状况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候可以知道该线程上次运行到哪儿了。

注意:程序计数器是惟一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的建立而建立,随着线程的结束而死亡。

2.2 Java 虚拟机栈

与程序计数器同样,Java 虚拟机栈也是线程私有的,它的生命周期和线程相同,描述的是 Java 方法执行的内存模型,每次方法调用的数据都是经过栈传递的。

Java 内存能够粗糙的区分为堆内存(Heap)和栈内存 (Stack),其中栈就是如今说的虚拟机栈,或者说是虚拟机栈中局部变量表部分。 (实际上,Java 虚拟机栈是由一个个栈帧组成,而每一个栈帧中都拥有:局部变量表、操做数栈、动态连接、方法出口信息。)

局部变量表主要存放了编译器可知的各类数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不一样于对象自己,多是一个指向对象起始地址的引用指针,也多是指向一个表明对象的句柄或其余与此对象相关的位置)。

Java 虚拟机栈会出现两种异常:StackOverFlowError 和 OutOfMemoryError。

  • StackOverFlowError: 若 Java 虚拟机栈的内存大小不容许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 异常。
  • OutOfMemoryError: 若 Java 虚拟机栈的内存大小容许动态扩展,且当线程请求栈时内存用完了,没法再动态扩展了,此时抛出 OutOfMemoryError 异常。

Java 虚拟机栈也是线程私有的,每一个线程都有各自的 Java 虚拟机栈,并且随着线程的建立而建立,随着线程的死亡而死亡。

扩展:那么方法/函数如何调用?

Java 栈可用类比数据结构中栈,Java 栈中保存的主要内容是栈帧,每一次函数调用都会有一个对应的栈帧被压入 Java 栈,每个函数调用结束后,都会有一个栈帧被弹出。

Java 方法有两种返回方式:

  1. return 语句。
  2. 抛出异常。

无论哪一种返回方式都会致使栈帧被弹出。

2.3 本地方法栈

和虚拟机栈所发挥的做用很是类似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

本地方法被执行的时候,在本地方法栈也会建立一个栈帧,用于存放该本地方法的局部变量表、操做数栈、动态连接、出口信息。

方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种异常。

2.4 堆

Java 虚拟机所管理的内存中最大的一块,Java 堆是全部线程共享的一块内存区域,在虚拟机启动时建立。此内存区域的惟一目的就是存放对象实例,几乎全部的对象实例以及数组都在这里分配内存。

Java 堆是垃圾收集器管理的主要区域,所以也被称做GC 堆(Garbage Collected Heap).从垃圾回收的角度,因为如今收集器基本都采用分代垃圾收集算法,因此 Java 堆还能够细分为:新生代和老年代:再细致一点有:Eden 空间、From Survivor、To Survivor 空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。

上图所示的 eden 区、s0 区、s1 区都属于新生代,tentired 区属于老年代。大部分状况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,若是对象还存活,则会进入 s0 或者 s1,而且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增长到必定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,能够经过参数 -XX:MaxTenuringThreshold 来设置。

2.5 方法区

方法区与 Java 堆同样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,可是它却有一个别名叫作 Non-Heap(非堆),目的应该是与 Java 堆区分开来。

方法区也被称为永久代。不少人都会分不清方法区和永久代的关系,为此我也查阅了文献。

2.5.1 方法区和永久代的关系

《Java 虚拟机规范》只是规定了有方法区这么个概念和它的做用,并无规定如何去实现它。那么,在不一样的 JVM 上方法区的实现确定是不一样的了。 方法区和永久代的关系很像 Java 中接口和类的关系,类实现了接口,而永久代就是 HotSpot 虚拟机对虚拟机规范中方法区的一种实现方式。 也就是说,永久代是 HotSpot 的概念,方法区是 Java 虚拟机规范中的定义,是一种规范,而永久代是一种实现,一个是标准一个是实现,其余的虚拟机实现并无永久带这一说法。

2.5.2 经常使用参数

JDK 1.8 以前永久代还没被完全移除的时候一般经过下面这些参数来调节方法区大小

-XX:PermSize=N //方法区 (永久代) 初始大小
-XX:MaxPermSize=N //方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen

相对而言,垃圾收集行为在这个区域是比较少出现的,但并不是数据进入方法区后就“永久存在”了。**

JDK 1.8 的时候,方法区(HotSpot 的永久代)被完全移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是直接内存。

下面是一些经常使用参数:

-XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小)
-XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小

与永久代很大的不一样就是,若是不指定大小的话,随着更多类的建立,虚拟机会耗尽全部可用的系统内存。

2.5.3 为何要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?

整个永久代有一个 JVM 自己设置固定大小上线,没法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,而且永远不会获得 java.lang.OutOfMemoryError。你可使用 -XX:MaxMetaspaceSize 标志设置最大元空间大小,默认值为 unlimited,这意味着它只受系统内存的限制。-XX:MetaspaceSize 调整标志定义元空间的初始大小若是未指定此标志,则 Metaspace 将根据运行时的应用程序需求动态地从新调整大小。

固然这只是其中一个缘由,还有不少底层的缘由,这里就不提了。

2.6 运行时常量池

运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池信息(用于存放编译期生成的各类字面量和符号引用)

既然运行时常量池时方法区的一部分,天然受到方法区内存的限制,当常量池没法再申请到内存时会抛出 OutOfMemoryError 异常。

JDK1.7 及以后版本的 JVM 已经将运行时常量池从方法区中移了出来,在 Java 堆(Heap)中开辟了一块区域存放运行时常量池。


——图片来源:http://www.javashuo.com/article/p-fqzrwcnd-bx.html

2.7 直接内存

直接内存并非虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,可是这部份内存也被频繁地使用。并且也可能致使 OutOfMemoryError 异常出现。

JDK1.4 中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel)缓存区(Buffer) 的 I/O 方式,它能够直接使用 Native 函数库直接分配堆外内存,而后经过一个存储在 Java 堆中的 DirectByteBuffer 对象做为这块内存的引用进行操做。这样就能在一些场景中显著提升性能,由于避免了在 Java 堆和 Native 堆之间来回复制数据

本机直接内存的分配不会收到 Java 堆的限制,可是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。

三 HotSpot 虚拟机对象探秘

经过上面的介绍咱们大概知道了虚拟机的内存状况,下面咱们来详细的了解一下 HotSpot 虚拟机在 Java 堆中对象分配、布局和访问的全过程。

3.1 对象的建立

下图即是 Java 对象的建立过程,我建议最好是能默写出来,而且要掌握每一步在作什么。
Java 对象的建立过程

Step1:类加载检查

虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,而且检查这个符号引用表明的类是否已被加载过、解析和初始化过。若是没有,那必须先执行相应的类加载过程。

Step2:分配内存

类加载检查经过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后即可肯定,为对象分配空间的任务等同于把一块肯定大小的内存从 Java 堆中划分出来。分配方式“指针碰撞”“空闲列表” 两种,选择那种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定

内存分配的两种方式:(补充内容,须要掌握)

选择以上两种方式中的哪种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是"标记-清除",仍是"标记-整理"(也称做"标记-压缩"),值得注意的是,复制算法内存也是规整的

内存分配并发问题(补充内容,须要掌握)

在建立对象的时候有一个很重要的问题,就是线程安全,由于在实际开发过程当中,建立对象是很频繁的事情,做为虚拟机来讲,必需要保证线程是安全的,一般来说,虚拟机采用两种方式来保证线程安全:

  • CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操做,若是由于冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操做的原子性。
  • TLAB: 为每个线程预先在 Eden 区分配一起内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配

Step3:初始化零值

内存分配完成后,虚拟机须要将分配到的内存空间都初始化为零值(不包括对象头),这一步操做保证了对象的实例字段在 Java 代码中能够不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

Step4:设置对象头

初始化零值完成以后,虚拟机要对对象进行必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希吗、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不一样,如是否启用偏向锁等,对象头会有不一样的设置方式。

Step5:执行 init 方法

在上面工做都完成以后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象建立才刚开始,<init> 方法尚未执行,全部的字段都还为零。因此通常来讲,执行 new 指令以后会接着执行 <init> 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算彻底产生出来。

3.2 对象的内存布局

在 Hotspot 虚拟机中,对象在内存中的布局能够分为 3 块区域:对象头实例数据对齐填充

Hotspot 虚拟机的对象头包括两部分信息第一部分用于存储对象自身的自身运行时数据(哈希码、GC 分代年龄、锁状态标志等等),另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机经过这个指针来肯定这个对象是那个类的实例。

实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各类类型的字段内容。

对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位做用。 由于 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),所以,当对象实例数据部分没有对齐时,就须要经过对齐填充来补全。

3.3 对象的访问定位

创建对象就是为了使用对象,咱们的 Java 程序经过栈上的 reference 数据来操做堆上的具体对象。对象的访问方式有虚拟机实现而定,目前主流的访问方式有①使用句柄②直接指针两种:

  1. 句柄: 若是使用句柄的话,那么 Java 堆中将会划分出一块内存来做为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息;
    使用句柄

  2. 直接指针: 若是使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象的地址。

使用直接指针

这两种对象访问方式各有优点。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 自己不须要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。

四 重点补充内容

4.1 String 类和常量池

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 方法,便须要建立新的对象。

再给你们一个图应该更容易理解,图片来源:https://www.journaldev.com/797/what-is-java-string-pool

String-Pool-Java

String 类型的常量池比较特殊。它的主要使用方法有两种:

  • 直接使用双引号声明出来的 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

尽可能避免多个字符串拼接,由于这样会从新建立对象。若是须要改变字符串的话,可使用 StringBuilder 或者 StringBuffer。

4.2 String s1 = new String(“abc”);这句话建立了几个字符串对象?

将建立 1 或 2 个字符串。若是池中已存在字符串文字“abc”,则池中只会建立一个字符串“s1”。若是池中没有字符串文字“abc”,那么它将首先在池中建立,而后在堆空间中建立,所以将建立总共 2 个字符串对象。

验证:

String s1 = new String("abc");// 堆内存的地址值
		String s2 = "abc";
		System.out.println(s1 == s2);// 输出 false,由于一个是堆内存,一个是常量池的内存,故二者是不一样的。
		System.out.println(s1.equals(s2));// 输出 true

结果:

false
true

4.3 8 种基本类型的包装类和常量池

  • Java 基本类型的包装类的大部分都实现了常量池技术,即 Byte,Short,Integer,Long,Character,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 缓存源代码:

/** *此方法将始终缓存-128 到 127(包括端点)范围内的值,并能够缓存此范围以外的其余值。 */
    public static Integer valueOf(int i) {
        if (i >= IntegerCache.low && i <= IntegerCache.high)
            return IntegerCache.cache[i + (-IntegerCache.low)];
        return new Integer(i);
    }

应用场景:

  1. Integer i1=40;Java 在编译的时候会直接将代码封装成 Integer i1=Integer.valueOf(40);,从而使用常量池中的对象。
  2. Integer i1 = new Integer(40);这种状况下会建立新的对象。
Integer i1 = 40;
  Integer i2 = new Integer(40);
  System.out.println(i1==i2);//输出 false

Integer 比较更丰富的一个例子:

Integer i1 = 40;
  Integer i2 = 40;
  Integer i3 = 0;
  Integer i4 = new Integer(40);
  Integer i5 = new Integer(40);
  Integer i6 = new Integer(0);
  
  System.out.println("i1=i2 " + (i1 == i2));
  System.out.println("i1=i2+i3 " + (i1 == i2 + i3));
  System.out.println("i1=i4 " + (i1 == i4));
  System.out.println("i4=i5 " + (i4 == i5));
  System.out.println("i4=i5+i6 " + (i4 == i5 + i6));   
  System.out.println("40=i5+i6 " + (40 == i5 + i6));

结果:

i1=i2   true
i1=i2+i3   true
i1=i4   false
i4=i5   false
i4=i5+i6   true
40=i5+i6   true

解释:

语句 i4 == i5 + i6,由于+这个操做符不适用于 Integer 对象,首先 i5 和 i6 进行自动拆箱操做,进行数值相加,即 i4 == 40。而后 Integer 对象没法与数值进行直接比较,因此 i4 自动拆箱转为 int 值 40,最终这条语句转为 40 == 40 进行数值比较。

参考