JVM练习室
深入理解JVM的内存区域
根据上一节的知识,我们所知道了简单的JVM的内存区域划分,所以,现在我们来深入理解一下JVM的内存区域。
我们由一段代码开始理解JVM对于内存区域的管理
/**
* VM参数
* -Xms30m -Xmx30m -XX:MaxMetaspaceSize=30m
*
*/
public class JVMObject {
public final static String MAN_TYPE = "man"; // 常量
public static String WOMAN_TYPE = "woman"; // 静态变量
public static void main(String[] args)throws Exception {
Person T1 = new Teacher();
T1.setName("张三");
T1.setSexType(MAN_TYPE);
T1.setAge(36);
Person T2 = new Teacher();
T2.setName("李四");
T2.setSexType(MAN_TYPE);
T2.setAge(18);
Thread.sleep(Integer.MAX_VALUE);//线程休眠
}
}
class Person{
String name;
String sexType;
int age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getSexType() {
return sexType;
}
public void setSexType(String sexType) {
this.sexType = sexType;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
当程序开始运行时我们将JVM管理内存的步骤抽象如下:
1、JVM申请内存(根据配置的JVM参数申请内存)
2、初始化运行时数据区,抽象的运行时数据区如下:
3、类加载
我们从程序的代码运行来模拟JVM的内存变化:
JVMObject类编译后,在类加载时,有两个静态变量:MAN_TYPE和WOMEN_TYPE,然后有两个类:JVMObject类和Person类也即JVMObject.class和将会加载在方法区,如下:
4、执行方法
执行main()方法,也即单独开启了一个线程,于是在栈区有了一个虚拟机栈,并且main()方法作为栈帧入栈,图示如下:
5、创建对象
当代码依次执行时,遇到new关键字时,JVM在堆区创建对象,且在虚拟机栈的栈帧中创建对象的引用,如图:
这就是开篇程序执行时,JVM对内存管理的抽象。
相较于上一篇JVM对内存的管理,我们引入了JVM管理内存中的堆的概念,JVM生成的对象都在堆区,在程序执行结束时,JVM会执行GC算法对堆内存进行垃圾回收(后面篇章细讲),现在就来说一下堆内存的划分:
堆被划分为新生代和老年代(Tenured),新生代又被进一步划分为 Eden 和 Survivor 区,最后 Survivor 由 From Survivor 和 To Survivor 组成。
可以抽象为:
对于堆内存的管理,主要是靠垃圾回收进行堆的管理
也即GC
GC的概念:GC- Garbage Collection 垃圾回收,在 JVM 中是自动化的垃圾回收机制,我们一般不用去关注,在 JVM 中 GC 的重要区域是堆空间。
我们也可以通过一些额外方式主动发起它,比如 System.gc(),主动发起。
可以修改上述代码:
/**
* VM参数
* -Xms30m -Xmx30m -XX:MaxMetaspaceSize=30m
*
*/
public class JVMObject {
public final static String MAN_TYPE = "man"; // 常量
public static String WOMAN_TYPE = "woman"; // 静态变量
public static void main(String[] args)throws Exception {
Person T1 = new Teacher();
T1.setName("张三");
T1.setSexType(MAN_TYPE);
T1.setAge(36);
for (int i = 0; i< 15; i++) {
System.gc();
}
Person T2 = new Teacher();
T2.setName("李四");
T2.setSexType(MAN_TYPE);
T2.setAge(18);
Thread.sleep(Integer.MAX_VALUE);//线程休眠
}
}
class Person{
String name;
String sexType;
int age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getSexType() {
return sexType;
}
public void setSexType(String sexType) {
this.sexType = sexType;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
然后我们可以使用相关的工具来监控JVM查看内存,也即映射JVM的运行信息
使用工具:
JHSDB:查看JVM的运行信息
jps命令:显示java进程
JVM配置参数:
-XX:+UseConcMarkSweepGC 使用CMS垃圾回收器
-XX:-UseCompressedOops 禁用压缩指针
使用过程如下:
1、Jdk1.8 启动 JHSDB 的时候必须将 sawindbg.dll(一般会在 JDK 的目录下)复制到对应目录的 jre 下(注意在 win 上安装了 JDK1.8 后往往同级目录下有一个jre 的目录)
2、最后复制过去后的文件搜索后应该是这样的:
3、在jdk1.8目录下执行以下命令
java -cp .\sa-jdi.jar sun.jvm.hotspot.HSDB
也即 :
可以查看JHSDB界面如下:
4、在idea中启动增加虚拟机参数
5、启动,使用jps查看进程
6、查看JVM进程的线程
7、查看方法区内所有的类的信息
8、寻找我们代码中的类
9、查看类的实例信息
我们可以通过工具来查看堆区的分代划分:
可以看到各个分区 的地址如下:
eden:
0x0000000012a00000 - 0x0000000013200000
from:
0x0000000013200000 - 0x0000000013300000
to
0x0000000013300000 - 0x0000000013400000
old:
0x0000000013400000 - 0x0000000014800000
分代划分是连续的
由堆的分代划分地址我们可以看出,两个类实例所在的堆区:
张三在老年代
所以为什么张三在老年代呢?
我们可以分析代码得出,T1实例也就是张三,在垃圾回收15次之前,但是他并没有被回收,因为T1一直有引用,所以对象存活,而T1对象的变化如下:
TI对象从Eden区经过垃圾回收后到From区,回收一次回收不掉后到了TO区,于是就在from - to区反复横跳,当对象头里的分代年龄达到15次之后就进入老年代
所以张三这个对象就处于老年代
然后我们可以通过Jhsdb查看栈:
jvm优化之栈的优化
栈帧之间的数据共享:
实验代码如下:
public class JVMStack {
public int work(int x) throws Exception{
int z =(x+5)*10;//局部变量表有, 32位
Thread.sleep(Integer.MAX_VALUE);
return z;
}
public static void main(String[] args)throws Exception {
JVMStack jvmStack = new JVMStack();
jvmStack.work(10);//10 放入main栈帧 10 ->操作数栈
}
}
我们根据工具来查看实验代码的栈信息:
将我们在主方法中进行方法调用,10会进操作数栈
我们可以从work方法的栈帧局部变量和主方法的栈帧中的操作数栈共享了一块内存地址,如图:
总结
栈和堆的功能
- 以栈帧的方式存储方法调用的过程,并存储方法调用过程中基本数据类型的变量(int、short、long、byte、float、double、boolean、char 等)以及对象的引用变量,其内存分配在栈上,变量出了作用域就会自动释放;
- 而堆内存用来存储 Java 中的对象。无论是成员变量,局部变量,还是类变量,它们指向的对象都存储在堆内存中
栈和堆的线程共享情况
- 栈内存归属于单个线程,每个线程都会有一个栈内存,其存储的变量只能在其所属线程中可见,即栈内存可以理解成线程的私有内存。
- 堆内存中的对象对所有线程可见。堆内存中的对象可以被所有线程访问。
空间大小
- 栈的内存要远远小于堆内存
内存溢出(OOM)
栈溢出
设置线程栈大小参数:-Xss
详情可以见官网:java
代码示例:
public class StackOverFlow {
public void king(){//方法不断执行-栈帧不断入栈(不出栈)
king();//
}
public static void main(String[] args)throws Throwable {
StackOverFlow javaStack = new StackOverFlow();
javaStack.king();
}
}
异常如下:
一些说明:
- hotSpot版本的虚拟机中栈的大小是固定的是不支持扩展的
- java.lang.StackOverflowError 一般的方法调用是很难出现的,如果出现了可能会是无限递归。
- OutOfMemoryError:不断建立线程,JVM 申请栈内存,机器没有足够的内存。(例如:极其有2G的内存,设置堆大小1G,方法区824M,则剩余给栈区的200M,此时你同时跑>200个线程,就会OOM,请勿模拟!!!!,因为虚拟机不会对整个栈区进行限制)
- 栈区的空间 M JVM 没有办法去限制的,因为 M JVM 在运行过程中会有线程不断的运行,没办法限制,所以只限制单个虚拟机栈的大小。
堆溢出
设置堆的初始堆参数: -Xms
出现堆内存溢出的情况:申请内存空间超出了最大堆内存空间
第一种申请不到足够空间:
代码示例:
/**
* VM Args:-Xms30m -Xmx30m -XX:+PrintGCDetails
* 堆内存溢出(直接溢出)
*/
public class HeapOom {
public static void main(String[] args)
{
String[] strings = new String[35*1000*1000]; //35m的数组(堆)
}
}
抛出异常如下:
抛出的异常是对象,可以通过Jhsdb工具查看
第二种情况:
垃圾回收不断执行,对象不断添加,也即垃圾回收占据资源>98%,回收资源<2%,在工作也叫做内存泄漏
实例代码:
/**
* VM Args:-Xms30m -Xmx30m -XX:+PrintGCDetails
* 造成一个堆内存溢出(分析下JVM的分代收集)
* GC调优---生产服务器推荐开启(默认是关闭的)
* -XX:+HeapDumpOnOutOfMemoryError
*/
public class HeapOom2 {
public static void main(String[] args) throws Exception {
List<Object> list = new LinkedList<>(); // list 当前虚拟机栈(局部变量表)中引用的对象
int i =0;
while(true){
i++;
if(i%1000==0) Thread.sleep(10);
list.add(new Object());// 不能回收2, 优先回收再来抛出异常。
}
}
}
异常如下:
堆OOM异常处理:
如果不是内存泄漏,就是说内存中的对象却是都是必须存活的,那么就应该检查 JVM 的堆参数设置,与机器的内存对比,看是否还有可以调整的空间,再从代码上检查是否存在某些对象生命周期过长、持有状态时间过长、存储结构设计不合理等情况,尽量减少程序运行时的内存消耗。
方法区溢出
设置方法区大小的虚拟机参数:
-XX:MetaspaceSize=10M
-XX:MaxMetaspaceSize=10M
方法区溢出的两种情况:
- 运行时常量池溢出。
- 方法区中保存的 Class 对象没有被及时回收掉或者 Class 信息占用的内存超过了我们配置。
我们可以根据cglib包来进行模拟异常,通过不断向堆中存放对象,也即不断的加载类,当设置了方法区大小后,就会出现OOM
实例代码如下:
import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;
import java.lang.reflect.Method;
/**
* 方法区导致的内存溢出
* VM Args: -XX:MetaspaceSize=10M -XX:MaxMetaspaceSize=10M
* */
public class MethodAreaOutOfMemory {
public static void main(String[] args) {
while (true) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(TestObject.class);
enhancer.setUseCache(false);
enhancer.setCallback(new MethodInterceptor() {
public Object intercept(Object arg0, Method arg1, Object[] arg2, MethodProxy arg3) throws Throwable {
return arg3.invokeSuper(arg0, arg2);
}
});
enhancer.create();
}
}
public static class TestObject {
private double a = 34.53;
private Integer b = 9999999;
}
}
设置参数,运行代码后,异常如下:
本机直接内存溢出
虚拟机参数设置:
-XX:MaxDirectMemorySize
出现异常的情况:
使用NIO的bytebuffer分配直接内存,模拟直接内存OOM
实例代码:
import java.nio.ByteBuffer;
/**
* VM Args:-XX:MaxDirectMemorySize=100m
* 限制最大直接内存大小100m
*/
public class DirectOom {
public static void main(String[] args) {
//直接分配128M的直接内存
ByteBuffer bb = ByteBuffer.allocateDirect(128*1024*1204);
}
}
异常详情如下:
异常处理:
由直接内存导致的内存溢出,一个比较明显的特征是在 HeapDump 文件中不会看见有什么明显的异常情况,如果发生了 OOM,同时 Dump 文件很小,可以考虑重点排查下直接内存方面的原因。
JVM的常量池
在逻辑划分上,常量池划分在方法区
Class文件常量池
在 class 文件中除了有类的版本、字段、方法和接口等描述信息外,还有一项信息是常量池 (Constant Pool Table),用于存放编译期间生成的各种字面量和符号引用。
我们可以通过反编译来查看.class文件中的常量,如下所示:
字面量是什么?
给基本类型变量赋值的方式就叫做字面量或者字面值。
例如:String a=“b” ,这里“b”就是字符串字面量,同样类推还有整数字面值、浮点类型字面量、字符字面量。
那么符号引用是什么?
符号引用以一组符号来描述所引用的目标。符号引用可以是任何形式的字面量,JAVA 在编译的时候一个每个 java 类都会被编译成一个 class文件,但在编译的时候虚拟机并不知道所引用类的地址(实际地址),就用符号引用来代替,而在类的解析阶段就是为了把这个符号引用转化成为真正的地址的阶段。
运行时常量池
运行时常量池(Runtime Constant Pool)是每一个类或接口的常量池(Constant_Pool)的运行时表示形式,它包括了若干种不同的常量:
从编译期可知的数值字面量到必须运行期解析后才能获得的方法或字段引用。(这个是虚拟机规范中的描述,很生涩)
运行时常量池是在类加载完成之后,将 Class 常量池中的符号引用值转存到运行时常量池中,类在解析之后,将符号引用替换成直接引用。
运行时常量池在 JDK1.7 版本之后,就移到堆内存中了,这里指的是物理空间,而逻辑上还是属于方法区(方法区是逻辑分区)。
在 JDK1.8 中,使用元空间代替永久代来实现方法区,但是方法区并没有改变,所谓"Your father will always be your father"。变动的只是方法区中内容的物理存放位置,但是运行时常量池和字符串常量池被移动到了堆中。但是不论它们物理上如何存放,逻辑上还是属于方法区的。
字符串常量池(无官方定义)
以 JDK1.8 为例,字符串常量池是存放在堆中,并且与 java.lang.String 类有很大关系。设计这块内存区域的原因在于:String 对象作为 Java 语言中重要的数据类型,是内存中占据空间最大的一个对象。高效地使用字符串,可以提升系统的整体性能。
String解析
String类分析
String 对象是对 char 数组进行了封装实现的对象,主要有 2 个成员变量:char 数组,hash 值。
源码如下:
在这里插入图片描述
String具有不可变性:
String 类被 final 关键字修饰了,而且变量 char 数组也被 final 修饰了。被 final 修饰代表该类不可继承,而 char[]被 final+private 修饰,代表了 String 对象不可被更改。Java 实现的这个特性叫作 String 对象的不可变性,即 String 对象一旦创建成功,就不能再对它进行改变。
如此设计的好处:
- 保证 String 对象的安全性。假设 String 对象是可变的,那么 String 对象将可能被恶意修改。
- 保证 hash 属性值不会频繁变更,确保了唯一性,使得类似 HashMap 容器才能实现相应的 key-value 缓存功能。
- 可以实现字符串常量池。在 Java 中,通常有两种创建字符串对象的方式,一种是通过字符串常量的方式创建,如 String str=“abc”;另一种是字符串变量通过 new 形式的创建,如 String str = new String(“abc”)。
String 的创建方式及内存分配
样例代码如下:
public class Location {
private String city;
private String region;
public String getCity() {
return city;
}
public void setCity(String city) {
this.city = city;
}
public String getRegion() {
return region;
}
public void setRegion(String region) {
this.region = region;
}
// JVM 首先会检查该对象是否在字符串常量池中,如果在,就返回该对象引用,否则新的字符串将在常量池中
// 被创建。这种方式可以减少同一个值的字符串对象的重复创建,节约内存。(str 只是一个引用)
public void mode1(){
String str ="abc";
}
// 在编译类文件时,"abc"常量字符串将会放入到常量结构中,在类加载时,“abc"将会在常量池中创建;
// 其次,在调用 new 时,JVM 命令将会调用 String的构造函数,同时引用常量池中的"abc” 字符串,
// 在堆内存中创建一个 String 对象;最后,str 将引用 String 对象。
public void mode2(){
String str =new String("abc");
}
// 使用 new,对象会创建在堆中,同时赋值的话,会在常量池中创建一个字符串对象,复制到堆中。
// 具体的复制过程是先将常量池中的字符串压入栈中,在使用 String 的构造方法是,会拿到栈中的字符串作为构方法的参数。
// 这个构造函数是一个 char 数组的赋值过程,而不是 new 出来的,所以是引用了常量池中的字符串对象。存在引用关系。
public void mode3(){
Location location = new Location();
location.setCity("深圳");
location.setRegion("南山");
}
// String 对象是不可变的, 编译器是进行了优化 String str= "abcdef";
public void mode4(){
String str2= "ab" + "cd" + "ef";//3个对象。效率最低。java -》class- java
}
// 编译器会进行优化
public void mode5(){
String str = "abcdef";
for(int i=0; i<1000; i++) {
str = str + "0";
}
// //优化
// String str = "abcdef";
// for(int i=0; i<1000; i++) {
// str = (new StringBuilder(String.valueOf(str)).append(i).toString());
// }
}
// String 的 intern 方法,如果常量池中有相同值,就会重复使用该对象,返回对象引用
/*1、new Sting() 会在堆内存中创建一个 a 的 String 对象,king"将会在常量池中创建
2、在调用 intern 方法之后,会去常量池中查找是否有等于该字符串对象的引用,有就返回引用。
3、调用 new Sting() 会在堆内存中创建一个 b 的 String 对象。
4、在调用 intern 方法之后,会去常量池中查找是否有等于该字符串对象的引用,有就返回引用。
所以 a 和 b 引用的是同一个对象。*/
public void mode6(){
//去字符串常量池找到是否有等于该字符串的对象,如果有,直接返回对象的引用。
String a =new String("king").intern();// new 对象、king 字符常量池创建
String b = new String("king").intern();// b ==a。
if(a==b) {
System.out.print("a==b");
}else{
System.out.print("a!=b");
}
}
public static void main(String[] args) {
}
}