运行时数据区
按照线程使用情况和职责分成两大类:
1)线程独享(程序执行区)
虚拟机栈,本地方法栈,程序计数器
特点:不需要垃圾回收
2)线程共享(数据存储区域)
堆和方法区
存储类的静态数据和对象数据
特点:需要垃圾回收
堆
是虚拟机所管理的内存中最大的一块,在虚拟器启动时创建,被所有线程共享。此区域的唯一目的就是存放对象实例,一般来说对象实例都是在这里分配内存,包括现对象、数组与运行时常量。
堆内存划分依据:
1.弱分代假说:大多数对象存活时间短。
2.强分代假说:熬过越多次的垃圾回收,就越难以被回收。
3.跨代引用假说:跨代引用的对象占少数。
根据这个学说堆被划分为两个区域:新生代和老年代
新生代与老年代
老年代比新生代生命周期长。
新生代与老年代空间默认比例 1:2:JVM 调参数,XX:NewRatio=2,表示新生代占 1,老年代占 2,新生代占整个堆的 1/3。
HotSpot 中,Eden 空间和另外两个 Survivor 空间缺省所占的比例是:8:1:1。
几乎所有的 Java 对象都是在 Eden 区被 new 出来的,Eden 放不了的大对象,就直接进入老年代了。
堆的特点
- 线程共享,整个 Java 虚拟机只有一个堆,所有的线程都访问同一个堆。而程序计数器、Java 虚拟机栈、本地方法栈都是一个线程对应一个。
- 在虚拟机启动时创建。
- 是垃圾回收的主要场所。
- 堆可分为新生代(Eden 区,From Survior,To Survivor)、老年代。
- Java 虚拟机规范规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。
- 关于 Survivor s0,s1 区: 复制之后有交换,谁空谁是 to。
- 不同的区域存放不同生命周期的对象,这样可以根据不同的区域使用不同的垃圾回收算法,更具有针对性。
- 堆的大小既可以固定也可以扩展,但对于主流的虚拟机,堆的大小是可扩展的,因此当线程请求分配内存,但堆已满,且内存已无法再扩展时,就抛出 OutOfMemoryError 异常。
对象分配过程
- new 的对象先放在 Eden 区,大小有限制
- 如果创建新对象时,Eden 空间填满了,就会触发 Minor GC,将 Eden 不再被其他对象引用的对象进行销毁,再加载新的对象放到 Eden区,特别注意的是 Survivor 区满了是不会触发 Minor GC 的,而是 Eden 空间填满了,Minor GC 才顺便清理 Survivor 区
- 将 Eden 中剩余的对象移到 Survivor0 区
- 再次触发垃圾回收,此时上次 Survivor 下来的,放在 Survivor0 区的,如果没有回收,就会放到 Survivor1 区
- 再次经历垃圾回收,又会将幸存者重新放回 Survivor0 区,依次类推
- 默认是 15 次的循环,超过 15 次,则会将幸存者区幸存下来的转去老年区 jvm 参数设置次数 : -XX:MaxTenuringThreshold=N 进行设置
- 频繁在新生区收集,很少在养老区收集,几乎不在永久区/元空间搜集
虚拟机栈
- 什么是栈帧?
栈帧(Stack Frame)是用于支持虚拟机进行方法执行的数据结构。
栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用至执行
完成的过程,都对应着一个栈帧在虚拟机栈里从入栈到出栈的过程。
栈内存为线程私有的空间,每个线程都会创建私有的栈内存,生命周期与线程相同,每个Java方法在执
行的时候都会创建一个栈帧(Stack Frame)。栈内存大小决定了方法调用的深度,栈内存过小则会导
致方法调用的深度较小,如递归调用的次数较少。 - 当前栈帧
一个线程中方法的调用链可能会很长,所以会有很多栈帧。只有位于JVM虚拟机栈栈顶的元素才是有效
的,即称为当前栈帧,与这个栈帧相关连的方法称为当前方法,定义这个方法的类叫做当前类。
执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。如果当前方法调用了其他方法,或者当前
方法执行结束,那这个方法的栈帧就不再是当前栈帧了。 - 什么时候创建栈帧?
调用新的方法时,新的栈帧也会随之创建。并且随着程序控制权转移到新方法,新的栈帧成为了当前栈
帧。方法返回之际,原栈帧会返回方法的执行结果给之前的栈帧(返回给方法调用者),随后虚拟机将会丢
弃此栈帧。 - 栈异常的两种情况
虚拟机栈可能出现的两种异常:
- 如果线程请求的栈深度大于虚拟机所允许的深度(Xss默认1m),会抛出StackOverflowError异常.
下面这个例子中,我们大约递归到2万多次就会堆栈溢出。
public class DemoError {
private static int cnt = 0;
private void call() {
cnt ++;
try {
call();
} catch (Throwable e) {
e.printStackTrace();
System.out.println(cnt);
}
}
public static void main(String[] args) {
DemoError demoError = new DemoError();
demoError.call();
}
}
#空间进一步缩小,递归的深度会进一步缩小
java -Xss256k DemoError
- 如果在创建新的线程时,没有足够的内存去创建对应的虚拟机栈,会抛出OutOfMemoryError异常。
本地方法栈
本地方法栈和虚拟机栈相似,区别就是虚拟机栈为虚拟机执行Java服务(字节码服务),而本地方法栈为虚拟机使用到的Native方法(比如C++方法)服务。这个方法栈主要就是用于java调用非java的native方法。
方法区
方法区(Method Area)是可供各个线程共享的运行时内存区域,方法区本质上是Java语言编译后代码存储区域,它存储每一个类的结构信息,例如:运行时常量池、成员变量、方法数据、构造方法和普通方法的字节码指令等内容。很多语言都有类似区域。
方法区的具体实现有两种:永久代(PermGen)、元空间(Metaspace)
方法区存储下面这几种数据类型:
第一:已经被加载的类信息
- 类型信息,比如Class(com.hero.User类)
- 方法信息,比如Method(方法名称、方法参数列表、方法返回值信息)
- 字段信息,比如Field(字段类型,字段名称需要特殊设置才能保存的住)
- 类变量(静态变量):JDK1.7之后,转移到堆中存储
- 方法表(方法调用的时候) 在A类的main方法中去调用B类的method1方法,是根据B类的方
法表去查找合适的方法,进行调用的。
第二:运行时常量池(字符串常量池):从class中的常量池加载而来,JDK1.7之后,转移到堆中存
储。字面量(就是定义的时候直接给了值,而不是一个等待赋值的变量)类型,引用类型–>内存地址
第三:JIT编译器编译之后的代码缓存
永久代和元空间的区别是什么?
1)JDK1.8之前使用的方法区实现是永久代,JDK1.8及以后使用的方法区实现是元空间。
2)存储位置不同:
永久代所使用的内存区域是JVM进程所使用的区域,它的大小受整个JVM的大小所限制。
元空间所使用的内存区域是物理内存区域。那么元空间的使用大小只会受物理内存大小的限
制。
3)存储内容不同:
永久代存储的信息基本上就是上面方法区存储内容中的数据。
元空间只存储类的元信息,而静态变量和运行时常量池都挪到堆中。
3)为什么要使用元空间来替换永久代?
字符串存在永久代中,容易出现性能问题和永久代内存溢出。
- 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代 溢出,太大则容易导致老年代溢出。
- 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
- Oracle 计划将HotSpot 与 JRockit 合二为一。
常量池(属于方法区)
有三种常量池:
class常量池:一个class文件只有一个class常量池
字面量:数值型(int、float、long、double)、双引号引起来的字符串值等
符号引用:Class、Method、Field等
运行时常量池:一个class对象有一个运行时常量池
字面量:数值型(int、float、long、double)、双引号引起来的字符串值等
符号引用:Class、Method、Field等
字符串常量池:全局只有一个字符串常量池,双引号引起来的字符串值
我们定义字符串的时候如果直接用""进行引用则是存储在字符串常量池中,如果用new String()则是存储在堆中。字符串常量池底层是一个拉链哈希表,用来存储和索引,所以字符串常量池不会存储重复的字符串。
看下面这几个例子来理解:
例子1
String a = "abc";
String b = "abc";
// true 我们知道 == 比较的是存储地址,所以我们可以看到这两个字符串都是存储在字符串常量池中的,并且引用的是同一个地址,那么肯定是只存储了一份。
System.out.println(a == b);
例子2
String a = "abc";
String b = new String("abc");
// false b 这个对象是通过new出来的,他不会放到字符串常量池中
System.out.println(a == b);
例子3
String a = "abc";
String c = "def";
String d = a + c;
String f = "abcdef";
// false 所以只有"" 的字符串在在字符串常量池,涉及到任何其它操作都不会存储在字符串常量池中
System.out.println(f == d);
// intern将字符串移动到字符串常量池中
// 因为我们调用函数intern d 的引用移动了字符串常量池,他们的地址也就一样了
System.out.println(f == d.intern());
例子4 map的底层实现和字符串常量池基本一样
Map<String, Integer> map = new HashMap<>();
map.put("通话", 51);
map.put("重地", 55);
// 两个字符串的hashcode一模一样
System.out.println("通话".hashCode());
System.out.println("重地".hashCode());
// hashcode 相同的时候会进行拉链,也就是先比较hashcode如果相同在比较值,最后放到这个槽的链表上
System.out.println(map.size());
程序计数器
程序计数器(Program Counter Register),也叫PC寄存器,是一块较小的内存空间,它可以看作是当
前线程所执行的字节码指令的行号指示器。字节码解释器的工作就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。分支,循环,跳转,异常处理,线程回复等都需要依赖这个计数器来完
成。
为什么需要程序计数器?
由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(针对多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线
程切换(系统上下文切换)后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
存储的什么数据?
如果一个线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是一个Native方法,这个计数器的值则为空。
异常:此内存区域是唯一一个在Java的虚拟机规范中没有规定任何OutOfMemoryError异常情况的区
域。
直接内存
直接内存并不是虚拟机运行时数据区的一部分,也不是Java 虚拟机规范中定义的内存区域。在JDK1.4 中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O 方式,
它可以使用native 函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer 对象
作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
本机直接内存的分配不会受到Java 堆大小的限制,受到本机总内存大小限制。
直接内存(堆外内存)与堆内存比较:
直接内存申请空间耗费更高的性能,当频繁申请到一定量时尤为明显
直接内存IO读写的性能要优于普通的堆内存,在多次读写操作的情况下差异明显
下面这个例子可以比较它们读写之前的差异:
public class ByteBufferCompare {
public static void main(String[] args) {
//allocateCompare(); //分配比较
operateCompare(); //读写比较
}
/**
* 直接内存 和 堆内存的 分配空间比较
* 结论: 在数据量提升时,直接内存相比非直接内的申请,有很严重的性能问题
*/
public static void allocateCompare() {
int time = 1000 * 10000; //操作次数,1千万
long st = System.currentTimeMillis();
for (int i = 0; i < time; i++) {
//ByteBuffer.allocate(int capacity) 分配一个新的字节缓冲区。
//非直接内存分配申请
ByteBuffer buffer = ByteBuffer.allocate(2);
}
long et = System.currentTimeMillis();
System.out.println("在进行" + time + "次分配操作时,堆内存 分配耗时:" +
(et - st) + "ms");
long st_heap = System.currentTimeMillis();
for (int i = 0; i < time; i++) {
//ByteBuffer.allocateDirect(int capacity) 分配新的直接字节缓冲区。
//直接内存分配申请
ByteBuffer buffer = ByteBuffer.allocateDirect(2);
}
long et_direct = System.currentTimeMillis();
System.out.println("在进行" + time + "次分配操作时,直接内存 分配耗时:" +
(et_direct - st_heap) + "ms");
}
/**
* 直接内存 和 堆内存的 读写性能比较
* 结论:直接内存在直接的IO 操作上,在频繁的读写时 会有显著的性能提升
*/
public static void operateCompare() {
int time = 10 * 10000 * 10000; //操作次数,10亿
ByteBuffer buffer = ByteBuffer.allocate(2 * time);
long st = System.currentTimeMillis();
for (int i = 0; i < time; i++) {
// putChar(char value) 用来写入 char 值的相对 put 方法
buffer.putChar('a');
}
buffer.flip();
for (int i = 0; i < time; i++) {
buffer.getChar();
}
long et = System.currentTimeMillis();
System.out.println("在进行" + time + "次读写操作时,非直接内存读写耗时:" +
(et - st) + "ms");
ByteBuffer buffer_d = ByteBuffer.allocateDirect(2 * time);
long st_direct = System.currentTimeMillis();
for (int i = 0; i < time; i++) {
// putChar(char value) 用来写入 char 值的相对 put 方法
buffer_d.putChar('a');
}
buffer_d.flip();
for (int i = 0; i < time; i++) {
buffer_d.getChar();
}
long et_direct = System.currentTimeMillis();
System.out.println("在进行" + time + "次读写操作时,直接内存读写耗时:" +
(et_direct - st_direct) + "ms");
}
}
从数据流的角度,来看:
非直接内存作用链:本地IO –>直接内存–>非直接内存–>直接内存–>本地IO
直接内存作用链:本地IO–>直接内存–>本地IO
直接内存的使用场景:
有很大的数据需要存储,它的生命周期很长
适合频繁的IO操作,例如:网络并发场景
堆划分的变迁
JDK 1.6:程序计数器、Java虚拟机栈、本地方法栈、堆、方法区[永久代](字符串常量池、静态变量、运行时常量池、类常量池)
JDK 1.7:程序计数器、Java虚拟机栈、本地方法栈、堆(字符串常量池、静态变量)、方法区[永久代](运行时常量池、类常量池)
JDK 1.8:程序计数器、Java虚拟机栈、本地方法栈、堆(字符串常量)、元数据(静态变量、运行时常量池、类常量池)
JDK1.8中变化最大是,的Perm永久区用Metaspace进行了替换
下面来看几个例子搞明白这个变迁:
import java.util.ArrayList;
import java.util.List;
public class StringDemo {
public static void main(String[] args) {
String base = "";
List<String> strings = new ArrayList<String>();
for (int i = 0; i < Integer.MAX_VALUE; i ++) {
String str = base += "abcdefg";
strings.add(str.intern());
}
}
}
java -XX:PermSize=8m -XX:MaxPermSize=8m StringDemo
在1.6的运行结果:
1.7运行结果:
永久带还保留但是字符串常量已经存到了堆中去,因为报的是堆内存溢出,同时我们设置PermSize的时候没有提示参数不存在。
1.8的运行结果:
可以看到1.8永久带彻底被移除,字符串常量存储到了堆内存中。