JVM内存结构总结
Java Virtual Machine:
Java二进制字节码的运行环境 -> 也可以说是Java的运行环境
好处:
一次编写,到处运行的基石,屏蔽各个平台的字节码等差异,对外提供了一个一致的环境
JVM中有一个内存管理机制,提供了垃圾回收功能
提供了数组越界异常的判断
多态的支持
比较:
jvm:java虚拟机,作为java最底层的支持,屏蔽了字节码等运行平台相关的东西
jre:jvm+基础类库,集成了java的虚拟机和基础的类库,类似集合类、线程类等
jdk:jre+编译工具,集成了编译工具之后才能对高级语言编译成机器语言
学习jvm可以理解底层的实现原理
常见的jvm:
Oracle:Hotspot
Eclipse:OpenJ9
下面知识基于HotSpot
学习路线:
ClassLoader
JVM内存结构:
方法区
堆
虚拟机栈
程序计数器
本地方法栈
执行引擎:
解释器
JIT及时编译器
GC垃圾回收
1.程序计数器(Program Counter Register)
作用:
java源代码 -> 二进制字节码(jvm指令) -> 通过解释器解释成机器码 -> 交给CPU处理
程序计数器就是在指令运行的时候记住下一条jvm指令的地址
根据地址信息就可以寻址并执行
当执行完此地址的指令的时候,就会把下一条指令地址存入程序计数器
元件为寄存器
特点:
线程私有:
java是支持多线程的,CPU的调度器会分配时间片给不同线程,没执行完就暂存
线程切换的过程中,会用到程序计数器来记录最后一条指令的地址,并且标识自己属于某线程,以做到线程私有
每一个线程都有属于自己的程序计数器
唯一一个不会存在内存溢出的区域
2.虚拟机栈:
即每一个线程运行时所需要的一个内存空间,给每一个线程都划分
如果有多个线程的话,就有多个虚拟机栈
虚拟机栈由栈帧组成,一次方法调用对应一个栈帧,内部包含了参数、局部变量和返回地址等数据,一个虚拟机栈可以有多个栈帧,像递归就是有多个栈帧
在每个虚拟机栈只能由一个活动栈帧,即一个线程内只有一个方法正在执行
问题辨析:
1.垃圾回收是否涉及虚拟机栈?
不涉及,每次栈帧运行完都会自动回收
2.栈内存越大越好吗?
-Xss可以指定栈内存
Linux、mac、Oracle默认都是1M,Windows取决于它的虚拟内存
栈内存和线程数成反比:因为物理内存都是一定的,栈内存*线程数=物理内存
并不是越大越好
3.方法内的局部变量是否线程安全?
局部变量不会,就算多个线程调用同一个方法,由于每个线程都会生成一个栈,调用时都会生成一个栈帧,而在栈帧里局部变量是私有的,所以不会影响
但共享的数据可能会造成线程不安全,例如static变量,可以使用ThreadLocal来控制
4.线程公有的需要考虑线程安全,线程私有的不需要考虑线程安全
传入和传出的参数需要考虑线程安全问题,因为可能有别的方法调用它
即如果逃离了方法的作用范围,就要考虑线程安全问题
栈内存溢出:
栈帧过多,导致栈内存溢出:
栈的大小是固定了,但是和递归一样不断调用的话,就会产生栈内存溢出问题,抛出StackOverFlowError的异常
栈帧过大,导致栈内存溢出:
如果一个栈帧的占用内存过大,也会导致栈内存溢出,就几个栈帧九八内存塞满了
循环引用导致栈内存溢出:
如果有两个类相互注入,比如学生类有学院类对象,学院类有学生类对象,就会产生循环引用错误,抛出异常
如果是使用JSON转换产生的错误,可以使用@JsonIgnore注解,忽略属性对象类中的转换
线程运行诊断:
案例一:CPU占用高
定位: top命令可以查看哪个进程对cpu占用过高, ps H -eo pid,tid,%cpu | grep 线程id
Jdk定位:jstack 进程id,可以列出线程中的状态,可以通过nid判断是哪个线程占用高
案例二:长时间没结果
有可能是多线程造成的线程死锁,导致无法出现结果
可以使用以上的Jdk定位,查看问题
程序死锁实例:两个线程都使用了synchronized锁住了资源,但是由于互相争抢导致两边都没有释放
实际开发中,都是先定位,然后再解决问题
3.本地方法栈:
有时候Java不能和底层的打交道,但是可以通过调用例如C++等本地方法来进行交互
例如:Object有一个native方法clone(),这个方法并没有实现,就是使用了本地C/C++来进行了实现
4.堆
我们使用new来创建的对象都会放在堆里面,创建对象都会使用堆内存
特点:
- 他是线程共享的,堆中的对象都需要考虑线程安全的问题
- 有垃圾回收机制
堆内存溢出:
就算有垃圾回收机制,但是如果现在还是有人在使用,那么堆中对象也不能作为垃圾进行回收
如果不断产生新对象,达到一定数量,就会导致堆内存耗尽,抛出OutOfMemoryError错误
可以使用-Xmx指定堆空间大小
堆内存诊断:
Jps:查看当前系统有哪些java进程
Jmap:查看某个时刻堆内存占用情况 idea控制台输入: jamp-heap id
程序新创建的对象会放在一个叫Eden的空间
Jconsole:GUI界面,功能很多,还可以持续查看堆内存占用情况 idea控制台输入: jconsole
Jvisualvm:GUI界面,可以检测和查看具体信息,使用界面内使用dump可以堆转储,定位当前时刻的所有信息
案例:垃圾回收之后,线程占用还是很高
先定位,后处理
使用jvisualvm工具,使用GUI界面进程查看具体的信息
5.方法区
JVM规范对于方法区的定义:方法区是所有jvm线程的共享的区域,存储了和类结构相关的信息:成员变量、成员方法、构造方法等。他在虚拟机创建的时候被创建,它逻辑上是堆的组成部分,但是各大厂商实现不同。方法区也会有内存溢出的情况。
在1.6里,使用了永久代(perGem)的方式实现。而在1.8里面,使用了元空间本地内存(Metaspace)实现。
方法区内存溢出:
拓展:类加载器ClassLoader可以用于加载类的二进制字节码。可以使用defineClass方法触发加载字节码成一个类(加载)
Classwriter作用就是生成类的二进制字节码。可以使用vist方法生成类的字节码(生成)
可以使用-XX:MaxMetaspaceSize指定元空间内存大小
可以使用类生成器+类加载器的方法新建一个类,如果类新建过多,就会产生方法区内存溢出的问题,导致OutOfMemoryError
内存溢出场景:
Spring:
Cglib动态生成代理类,详见之前文档中的aop
Mybatis:
Cglib动态生成mapper接口(实现类)
运行时常量池:
在运行程序之前,java会把当前的类编译成二进制字节码,包含了类的基本信息、常量池、类方法定义,字面量信息,还包含了虚拟机指令等数据,然后放入常量池中
反编译:可以在console使用javap -c 类名.class来反编译一个class文件,用于把二进制字节码转我们能看的代码
反编译之后可以看到,有一个常量池的表,它对应了在编译后我们生成的class文件中的指令的操作,指令需要做的操作可以去常量池表中寻找操作方法,都有一一对应的操作
在程序运行的时候,常量池中的数值会放入运行中常量池中,并把里面的虚拟地址变为真实地址,以此让机器可以寻址
查看字节码:
Constant pool:常量池
Code:代码块,里面的 #* 标识常量池中的虚拟地址,运行的时候会变成真实地址
LocalVariableTable:局部变量表,查看当前存储东西的位置
StringTable:字符串池
字符串池是什么:
底层是以HashMap来实现的。
当我们创建字符串符号的时候,就会把字符串符号放入常量池,当发生常量拼接,直接赋值、new字符串对象操作的时候,就会把这个字符串符号放入字符串池,放入对象到堆中,并返回引用给某个对象。如果我们需要去新建一个字符串符号,就会先去字符串池寻找有没有这个字符串,如果有直接返回引用,没有就会放一个新的进入字符串池,并返回引用。
字符串池和常量池的关系:
当运行的时候,符号只是存放在常量池中,并不会成为一个String对象。当它开始执行到新建对象赋值这一块的时候,就会把常量池中的符号拿出来,并在StringTable中查找是否有这个对象,如果没有就新建出一个String对象,放入StringTable中,如果有就直接把这个引用拿出来使用
懒加载:使用的时候再创建,然后才放进去串池中
字符串拼接(变量拼接):
在字节码中可以查看当两个字符串拼接的时候,是先进行了新建StringBuilder对象,拼接完成后进行toString输出。总的来说,当进行字符串拼接的时候,如果拼接是变量的话,就要新建了一个对象出来放入堆中,最后调用了toString方法进行输出,但没有把最后的结果放入字符串池中
字符串池的编译时优化(常量拼接):
当我们使用常量池中的符号进行拼接的时候(而不是使用对象来拼接),常量池中符号会进行编译时的优化,自动拼接起来,然后会先在字符串池中寻找拼接好的字符串,如果有就直接返回引用并存入堆和字符串池中,如果没有再新建一个对象出来, 返回引用存入堆和字符串池中
字符串池中字符串的延迟加载:
即一开始字符串池并不会有全部的常量,当发现一个没见过的常量的时候,例如new String("a"),就把a加入字符串池,并在其他地方拿出来使用。如果是见过的常量,那么就直接拿引用出来使用。
特性:
常量池中的字符串仅仅是符号,第一次用的时候才会变为一个对象
JVM利用串池的机制,来避免重复创建字符串对象
字符串拼接变量用的是StringBuilder(1.8)
字符串拼接常量是编译器优化
可以使用intern方法,主动尝试将串池中还没有的字符串对象放入串池中,如果没有就放入并返回引用,有就不会放入并返回引用
(示例)当现在使用有String s = new String("a") + new String("b");
先在运行时常量池中加入a符号、b符号,然后要创建String对象,就要在堆中创建一个String对象ab,并同时把a、b放入字符串池。但现在s是一个字符串对象ab,而且由于是变量拼接,没有在里面存放ab这个字符串,所以调用intern方法就可以把这个引用放入字符串池和堆中,这时s的引用等于"ab"的引用。
如果在里面已经有ab这个字符串了,那么s的引用不会等于"ab"的引用
(JDK1.6)在JDK1.6中,会复制一个新的对象放入字符串池。
面试题:
String s1 = "a";
String s2 = "b";
String s3 = "a" + "b"; // 编译优化 -> ab
String s4 = s1 + s2; // new String("ab")放入堆中
String s5 = "ab";
String s6 = s4.intern();
// 问
// s3是字符串池中的地址,s4是堆中的地址 -> false
System.out.println(s3 == s4);
// s5是字符串池中的地址 -> true
System.out.println(s3 == s5);
// s6是s4在字符串池中找到了ab,返回字符串池中的引用 -> true
System.out.println(s3 == s6);
String x2 = new String("c") + new String("d"); // new String("cd")
x2.intern();
String x1 = "cd";
// 问,如果调换了【最后两行代码】的位置呢,如果是jdk1.6呢
System.out.println(x1 == x2);
// 如果是jdk1.8,返回true,x1是字符串池中的引用,x2是堆中地址放入了字符串池中,那么就是说,两边的地址是相等的
// 如果是jdk1.6,返回false,x2放入的时候放了一个新的地址给他,而x1获取的时候就获取了新的地址,所以并不相等
位置:
Jvm1.6之前,字符串池是放在永久代中,即方法区的常量池里面,而这会导致垃圾回收效率低下。而在Jvm1.6之后,字符串池的位置就从永久代变成了堆中。
StringTable垃圾回收:
当字符串池的内存不够的时候,就会触发垃圾回收机制,然后GC会把字符串池中已经没用的字符串地址清除,以腾出空间给下面的地址使用。
StringTable调优:
场景:使用一个List存放一个480000个词的字典,而这480000个单词都要放入字符串池中
速度调优方案:调整-XX:StringTableSize=桶个数
由于StringTable底层是由HashMap实现的,而HashMap的性能和他的大小相关。如果HashMap桶多了,那么就表示元素就比较分散,hash碰撞的几率就小,查找速度变快。反之就会变慢。可以通过调整桶的个数,来调整hash碰撞的几率,从而加快/减慢储存的速度。
看回场景:如果我们桶的数目改变了,那么读取速度就会收到相应影响。
内存调优方案:考虑当前处理的字符串可以从池中获取
当使用采用intern方法,让同一字符串仅入池一次,其余重复的就拿池内已经存放了的地址。
看回场景:如果我们使用了intern方法,那么在字符串池中,对于每一个相同的字符串都只会存在一个地址,而对比原来没有使用intern的时候,字符串池内存占用会更小。
扩展:HashMap性能
HashMap存入数据的时候,会计算key的hashcode,然后放入一个数组里面。如果此时,计算出来的hashcode已经在里面了,那么同一hashcode的元素就会组成一个链表。这个数组每一个位置叫一个"桶"(bucket),如果存放的数据多了,hash碰撞几率增加,那么在同一hashcode里面就需要链表顺序查找元素,这样就很慢了。反之就会变快。 可以通过负载因子,平衡桶和链表的长度,默认为0.75
6.直接内存Direct Memory
常见于NIO(块I/O)操作中的ByteBuffer,ByteBuffer里面就是使用了数据缓冲区,也就是直接内存。ByteBuffer不归JVM管理,而是属于操作系统内存
由于是操作系统内存,所以它的分配和回收成本较高,但同时读写能力强
内存溢出:
由于它不受控与JVM的GC,所以当调用它的时候,将会产生内存溢出的问题
内存释放原理:
由于它占用的是操作系统的内存,所以当需要释放的时候,JVM会调用本地Unsafe对象的freeMemory方法进行内存释放。JVM的垃圾回收只能对JVM内部例如堆内存、元空间等位置进行释放回收。详情可以看DirectByteBuffer类源码,它调用Cleaner(虚引用)来监测并进行内存释放。
拓展:IO和NIO
IO属于流操作,一次只能与外界交互一个字节,当未完成的时候会阻塞IO。
NIO属于块操作,以块的方式与外界交互,可以规模化的传输数据,但缺少了IO的简单性。NIO流里面需要在操作系统内存开辟直接内存ByteBuffer作为缓冲区,这个缓冲区连接着系统缓冲区和Java缓冲区,这样Java缓冲区就能直接访问到系统缓冲区的资源。