1. 描述一下java内存结构
java内存结构主要由5部分组成,包括栈、本地方法栈、程序计数器、堆、方法区(非堆)。其中方法区和堆是线程共用的,注意:这里5部分java内存结构是由jvm规范所规定的,不同的java虚拟机可以采用不同的实现方式,据我所知,一般方法区的实现会由有差异,上面描述的5部分可以如图:
1.1 栈
属于线程本身,里面主要存储了局部变量表和操作数。局部变量表里面存储了基本的类型和对象的引用等。并且局部变量表是由许多slot组成的,在编译的时候slot便定下来数量了。如果栈的深度超过了jvm规定的深度,会抛stackoverflow的异常,如果内存大小小于栈启动线程的大小,便会抛出oom的异常。可以通过-Xss10M,调节方法栈的大小。
1.2 本地方法栈
同栈一样,只是给本地方法用。
1.3 程序计数器
线程上下文切换的时候,记录当前线程执行到的字节码的行号。
1.4 堆
多个线程共用,几乎所有对象都是在堆中创建的,java内存回收的主要区域,java堆一般是不可变的,可以用-xmx和-xms来设置堆的大小。
1.5 方法区
方法区也是共用的,存储类信息、静态变量和常量以及jit编译的字节码信息也存储在这里。并且方法区可以选择不实现垃圾回收。在jdk8后采用元空间替代方法区。
1.6 直接内存
nio中mmap,可以通过直接内存将java内存映射到操作系统的内存上。避免了java堆和Native堆的数据的来回复制。
2.什么是oop-Klass模型
2.1 Klass模型
在理解元空间和运行时常量池之前,我们有必要了解到java的类和对象究竟是如何在内存中存储的。众所周知,java是由C++写的,而在类加载的时候,其实就是将java字节码读入到了内存中。JVM解析字节码,并且生成一个Klass对象。
Klass对象其实就是对一个java类的描述,可以看他的一些属性:
_annotations:保存该类的所有注解 ;
_java_fields_count:已声明的Java字段数量;
_constants:保存该类的常量池指针
......
所以总结出来就是,jvm通过类加载器将字节码文件加载到内存中,并且在元空间生成一个Klass文件,同时基于Klass对象创建一个java的Class对象,并且将静态变量附在Class对象的末尾。
可以看出,Klass对象在元空间,Class对象在堆,静态变量也在堆中。
2.2 oopDesc
oopDesc其实就是c++对java对象的描述,里面分成3部分,分别是对象头、实例数据和对象填充。
对象头:对象头分成MarkWord和类的元数据信息指针两部分,MarkWord包括hash码、gc分代年龄、锁状态标志、偏向锁Id等;元数据信息其实就是执行对象所属类的元数据信息。
实例数据:即对象里各种类型字段的内容。
对象填充:对象要求是8的倍数,不是8的倍数的话需要补齐。
再补充一点,java提供了-XX:+UseCompressedOops这个参数,可以让64为地址的寻址压缩成32位,再堆中32位的指针引用在4个字节,但是64位的指针引用占8个字节。这样会导致内存占用更多。所以在64为机器中,可以将对象地址分为堆的基地址+偏移量,将偏移量/8保存到32位地址中。实现了指针的压缩。
3.静态常量池、运行时常量池和字符串常量池
3.1 静态常量池
静态常量池其实就是java字节码的constant pool部分,是一段二进制数据。
3.2 运行时常量池
运行时常量池其实就是将将java字节码中的constant pool加入到内存中在方法区中形成的内存数据。里面主要存储两部分内容,一是符号引用(比如类名、字段名称、方法名),通过symbol Table存储;二是字符串常量池的引用数据,也即String Table。
3.3. 字符串常量池
字符串常量池,其实就是为了让字符串能够重复利用,当新建一个一个字符串中,会首先去堆中字符串常量池查找是否有该字符串,如果有便返回元空间中字符串的引用。所以jvm在缓存字符串的时候,其实是会新建一个String对象,这个对象的地址存储在元空间的String Table中,真正的对象是存储在堆中。所以我们一般任务在jdk1.8后字符串常量池都是存储在堆中的。
3.4 关系
4. 什么是方法区?
4.1 方法区的迭代
jdk1.6时,方法区是其实是堆的一部分,这个时候字符串常量池,运行时常量池和静态变量都是存储在这里面的。这个时候用永久代来实现的方法区,即没有垃圾回收。string的intern方法可以在运行时将字符串放入到字符串常量池中,所以可能会导致oom。
所以在jdk1.7的时候,将字符串常量池和静态变量移入到了堆中.
后来在jdk1.8的时候,直接废弃掉了永久代,采用元空间metaspace来实现方法区.并且元空间是放入到直接内存中的。
4.2元空间
4.2.1.元空间发展历程
栈、方法区、元空间、本地方法栈、方法区都是一个逻辑概念,是jvm规范,即任何虚拟机都有这几部分。但是在具体实现上,可以不同,比如java6的时候,堆分成年轻代,老年代和永久代,而方法区就放在堆的永久代中,永久代没有gc,所以如果字符串常量过多,会导致永久代溢出。以前调节方法区大小其实就是调节永久代的大小,也即-XX:PerGermSize=10M或者-XX:MaxPerGermSize=10M。在java8的时候,字符串常量池和静态变量放入到了java堆中,但是运行时常量池(java的字节码类信息加载到内存中)放入到了元空间中(可以通过以下三个参数改变元空间的信息:)
-XX:MetaSpaceSize=10M,默认是不限制,即直接使用本地内存的空间。
-XX:MaxMetaSpaceSize=10M设置元空间最到大小为10M
-XX:MaxMetaSpaceFreeRatio,默认是70%,表示GC过后空闲元空间最多占比,如果大于该值,元空间便会缩小。
-XX:MinMetaSpaceFreeRatio,默认是40%,如果小于该值,便会扩大元空间。
4.2.2 元空间的组成
元空间分成Klass区和非Non-Klass区,其中Klass主要存储类的元数据信息,Non-Klass区是存储运行时常量池和即时编译器的字节码文件的信息。
1. Klass区
这块区域其实就是前面用来存储java的Klass文件的地方,默认是1G。
有时会通过通过jmap查看jvm内存的时候会看到有一块区域叫ccs,其实就是开启压缩指针过后的Klass区,可以通过-XX:CompressedClassSpaceSize来进行设置。如果开启压缩指针过后,这块区域因为地址是起始地址+偏移量,为了实现这一效果,应该是一片连续的内存。
2. no-Klass区
它主要存储的是运行时常量池和本地代码缓存(jit即时编译的代码放在这里)。这是一块不连续的区域。
4.2.3. 触发元空间gc的时机
元空间的超过阈值或者ccs区超过阈值,所以元空间不要设置得太小。并且可以通过MaxMetaSpaceSize来设置元空间的大小。也可以通过CompressedClassSpaceSize设置Klass区的大小,并且jvm运行过后大小便不能更改。
3.5 元空间的内存分配
3.5.1 元空间内存分配架构
1. 元空间每次向操作系统要2Mb大小的Node,这些Node组成一个链表。ccs区是一个很大的区域,所以也把他当成一个1个g的节点
2. 每个Node下面会分成很多chunk,其中chunk可以是1k,4K和64k,根据类加载器需要,划分不同的 chunk,比如bootstrap类加载器便一次分配4m的内存。
3.类加载器每次根据分配的chunk得到他当前需要的资源。
3.5.2 匿名类的处理
由于匿名类的存活时间很短,所以没有必要让他占用元空间的大量时间,所以匿名类的元空间是属于匿名类本身的,而不属于它的类加载器。同时根据类加载器过程,可以看出应该减少小的类加载器的诞生。
5.OutOfMemory和StackOverflow的区别是什么?
5.1 oom
oom其实就是内存超过了可用限制,一般是内存超过了可用的额度,便会抛出oom的异常。通常抛出oom的异常会由以下几种场景。
1. heap space:堆中抛出oom的异常,堆中存在内存泄漏或者大对象的时候,会抛出oom。一般用-Xms和-Xmx设置堆内存大小。并且可以加上参数-XX:+HeapDumpOnOutOfMemoryError,在出现oom的时候能够自动保存堆内存快照。
2. metaspce/pergem space:元空间或者永久代抛出oom的异常,一般是加载很多动态代理的类,或者字符串常量池超过永久代限制的,会抛出oom。可以通过-XX:MetaSpaceSize设置元空间的大小。
3.Native heap:一般是执行native方法导致堆内存不足,会抛出oom。
4.GC overhead Limit Exeeded:没有足够的空间gc会抛出该异常。
5.栈空间也会抛出oom的异常,在某些虚拟机中,栈是可以动态扩展的,如过需要的栈的大小超过虚拟机内存的大小便会抛出oom。注意,hotspot虚拟机是不会动态扩展的,所以只会抛出stackoverflow的异常。
5.2 StackOverflow
StackOverflow主要表示栈溢出,当方法调用超过栈的深度的时候,就会抛出StackOverFlow。可以通过-Xss设置栈的大小。
6.对象分配的优化-TLAB
1.什么是TLAB
TLAB是虚拟机在堆内存的eden空间中划分出来的一块线程专属的区域,它是线程安全的。当对象分配的时候,如果jvm开启了jit,首先会考虑在栈上分配。如果在栈上分配失败后,便会在这里分配。
2.为什么要有TLAB
TLAB其实就是利用空间解决对象分配的并发问题。由于每个线程有自己单独的对象分配空间,所以不存在线程共享的问题(堆内存一定是对象共享的这一说法其实是不严谨的)。TLAB可以看做是给每个线程划分了一个界限,圈住的这一部分区域,一定是独属于线程的。所以在这部分上分配的对象到达gc分代年龄过后也可能加入到老年代。并且TLAB空间满后,便会申请的新的区域,但是以前分配的对象地址并不会改变。
3.TLAB的优缺点以及解决办法
如果待分配对象需要的内存空间超过了TLAB的剩余空间的时候,可以通过设置一个"最大浪费空间"的值。如果待分配对象的空间超过"最大浪费空间",便会直接在堆中分配内存;否者,便会重新分配一个TLAB,并且分配内存。
参考资料
1.周志明.深入理解java虚拟机
2.OutOfMemory和StackOverflow的区别是什么
3.深入理解堆外内存 Metaspace 深入理解堆外内存 Metaspace_Javadoop
4.https://www.bilibili.com/read/cv13294155/] https://www.javadoop.com/post/metaspace