Java虚拟机拥有管理内存的权利。
一、运行时数据区
在Java程序执行的过程中,Java虚拟机会将它管理的内存分为若干个不同数据区域(JDK1.8与之前版本不同)
线程私有:
- 虚拟机栈
- 本地方法栈
- 程序计数器
线程共享:
- 堆
- 方法区
- 直接内存(非运行时数据区的一部分)
1 程序计数器
可看做当前线程所执行的字节码的**行号指示器**
特点:
-
线程私有
-
生命周期:与线程共存亡
-
一块较小的内存空间,存储字节码行号;
-
是唯一一块不会出现OutOfMemoryError的内存区域;
作用:
- 字节码解释器通过改变程序计数器的值来选取下一条需要执行的字节码指令,从而实现代码的流程控制;
- 多线程情况下,用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪了。
2 Java虚拟机栈
描述的是Java方法执行的线程内存模型:每个方法被执行时,Java虚拟机都会同步创建一个栈帧(用于存储局部变量表、操作数栈、动态链接、方法出口信息),每个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
特点:
- 线程私有
- 生命周期:与线程共存亡
- 存储栈帧、栈帧中存储…
- 会出现两种错误:
- StackOverFlowError :(stack内存不允许动态扩展时)当线程请求的栈的深度超过当前Java虚拟机栈的最大深度时报错;
- OutOfMemoryError :(stack内存允许动态扩展时)如果虚拟机的动态扩展栈时无法申请到足够的空间,则报异常。(HotSpot虚拟机是不支持动态扩展的,但如果是手动申请栈空间失败了也会报OOM异常)
局部变量表
存放内容:
- 方法参数;
- 方法体内的局部变量;
(这些数据类型包括:基本数据类型、对象引用、returnAddress类型
3 本地方法栈
基本功能和Java虚拟机栈基本一样。
和Java虚拟机栈的区别是:
- Java虚拟机栈描述Java方法的执行;
- 本地方法栈描述Native方法的执行;
4 堆
特点:
- 线程共享
- 生命周期:与虚拟机共存亡
- Java虚拟机所管制内存中最大的一块
- 唯一目的:存放实例对象(几乎所有的实例对象和数组都在这里分配内存)
- GC的主要区域
- 最容易出现OutOfMemoryError错误
堆分代
分代原因:
由于堆是GC的主要区域,从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以
对Java堆从理念上进行划分,而并非是具体实现的进一步划分。
划分目的:
更好地回收内存,或更快的分配内存。
不同版本堆内存划分:
JDK 7及之前的版本
- 新生代(有Eden区、两个Survivor区组成)
- 老年代
- 永久代
JDK8版本后:
永久代被元空间取代,元空间使用的是直接内存。
1、新生代的垃圾回收机制:
大部分情况下,对象先在Eden区域分配,在一次新生代垃圾回收后,如果对象还存活,则进入S0,并且对象年龄+1(第二次新生代垃圾回收后,Eden和S0中的存活对象都进入到S1中,S0此时空,以后反复此操作,每次都有一个Survivor区是空的)。
2、进入老年代:
- 当它的年龄到达一定程度(默认:15),就会被晋升到老年代。对象晋升到老年代的年龄阈值,可以通过参数
-XX:MaxTenuringThreshold
来设置。- 不用等到经历15次,如果一批对象总大小>=当前Survivor区内存的50%,则>=这批对象年龄的对象就会被转移到老年区。
5 方法区
栈堆方法区交互关系
特点:
- 线程共享
- 存储:已被虚拟机加载的类信息、静态变量、常量、即时编译器编译后的代码等数据
- GC较少出现,但并非不出现
虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。
方法区和永久代的关系
方法区:是一个概念,并没有具体的实现 (类似于接口)
永久代:是HotSpot虚拟机中对方法区的一种实现方式 (类似于接口的实现类)
其他的虚拟机实现并没有永久代这一说法
为什么将永久代(PermGen)替换为元空间(MetaSpace)
- 永久代有JVM本身设置的固定内存大小上限,而元空间使用直接内存,受本机可用内存的限制,使得溢出的几率减小。
- Java虚拟机能够加载多少类可直接由系统的实际可用空间来控制,使得能够加载更多的类。
运行时常量池
方法区的Class文件信息,Class常量池和运行时常量池的三者关系
特点:
- 方法区的一部分;
- 常量池将在类加载后存放到方法区的运行时常量池中;
- 当常量池无法再申请到内存时会抛出OutOfMemoryError错误
常量池概念
Class文件的一部分,用于存放编译器生成的各种字面量和符号引用。
字符串常量池
是JVM为了提升性能和减少内存消耗对字符串(String类)专门开辟的一块区域,只要目的是为了避免字符串的重复创建。
String aa = "ab"; // 放入常量池
String bb = "ab"; // 从常量池中查找
System.out.println(aa == bb);
1 发展历史
版本 | 地址 |
---|---|
JDK 1.7 之前 | 运行时常量池逻辑包含字符串常量池 存放在方法区(此时HotSpot虚拟机对方法区的实现是永久代) |
JDK 1.7 时 | 字符串常量池被从方法区拿到了堆中,运行时常量池剩下的东西还在方法区(Hotspot永久代)。 |
JDK1.8后 | 元空间取而代之,这时候字符串常量池还是在堆,运行时常量池还在方法区(元空间)。 |
2 String类型常量在“+”时发生了什么?
对于编译期可以确定值的字符串(常量字符串:“aa“),JVM会将其放入字符串常量池中。
并且,字符串常量拼接得到的字符串常量,在编译期间就已经被放在了字符串常量池中(Javac编译期的常量折叠优化)
String str3 = "str" + "ing"; // 编译器给优化成了 String str3 = "string";
String str1 = "str";
String str2 = "ing";
String str5 = "string";//常量池中的对象
System.out.println(str3 == str5);//true
只有编译器在程序编译期就可以确定的值的常量才可以被折叠:
- 基本数据类型常量、字符串常量;
- final修饰过的基本数据类型和字符串变量;
3 String类型变量在“+”时发生了什么?
str1、str2、str3都属于字符串常量池中的对象的引用,引用的值在程序编译期是无法确定的,编译器无法对其进行优化。
String类型的引用变量在“+”时,实际是通过StringBuilder调用append()方法来实现的,拼接完之后调用toString()得到一个String对象。
String str1 = "str";
String str2 = "ing";
String str4 = str1 + str2; //在堆上创建的新的对象
/*上一行代码等同于:
String str4 = new StringBuilder().append(str1).append(str2).toString();
*/
因此,str4并不是字符串常量池中存在的对象,而是属于堆上的新对象。
不过,字符串使用final关键字声明后,就又可以让编译器来做“常量折叠优化”了。
final String str1 = "str";
final String str2 = "ing";
// 下面两个表达式其实是等价的
String c = "str" + "ing";// 常量池中的对象
String d = str1 + str2; // 常量池中的对象
System.out.println(c == d);// true
如果将代码修改为:编译器在运行时才能知道其确切的值,就无法优化了!
final String str1 = "str";
final String str2 = getStr();
String c = "str" + "ing";// 常量池中的对象
String d = str1 + str2; // 在堆上创建的新的对象
System.out.println(c == d);// false
public static String getStr() {
return "ing";
}
4 String str1 = new String(“abcd”)
只要使用new的方式创建对象,便需要创建新的对象!
JVM先在字符串常量池中查看有没有“abcd”这个字符串对象
- 有,直接在堆中创建“abcd”字符串对象,然后返回该对象堆中地址
- 无,先在字符串常量池中创建“abcd”字符串对象,再在堆中创建一个“abcd”字符串对象,最后将堆中这个“abcd”字符串对象地址返回
这样str1指向堆中创建的“abcd”字符串对象。
5 String.intern()方法
作用:
- 如果字符串常量池中已经包含了一个等同于此String对象内容的字符串,则返回字符串常量池中该字符串的引用;
- 如果没有,将堆中此对象的引用直接放到常量池中(常量池中不需要再存储一份对象了,可以直接存储堆中的引用。这份引用指向String引用的对象。 也就是说引用地址是相同的)
直接内存
- 不是虚拟机运行时数据区的一部分
- 会导致OutOfMemoryError错误出现
- 本机直接内存的分配不会受到Java堆的限制,但是既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。
二、HotSpot虚拟机对象探秘
以上大概知道了虚拟机内存的情况,下来了解一下HotSpot虚拟机在Java堆中对象创建、布局和访问的过程。
1、对象的创建
(背下来)
(1)内存分配的两种方式:
Step2中内存的分配方式有2种:指针碰撞 和 空闲列表 两种,选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由其所采用的垃圾回收器是否带有压缩整理功能决定。
(2)内存分配的并发问题
在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:
- CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
- TLAB: 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配
2、对象的内存布局
对象的内存布局分为3块:对象头、实例数据、对齐填充。
3.对象的访问定位
(1)句柄
- Java堆会划分出一块内存来作为句柄池
- reference中存储的就是对象的句柄地址
- 句柄中包含了对象实例数据与类型数据各自的具体地址信息
(2)直接指针
- Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息
- reference中存储的直接就是对象的地址
这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。