JVM内存模型
栈:一旦一个线程开始运行,就会在栈内开辟一块空间,供当前线程使用的局部变量。
栈帧:只要某个线程开始运行一个方法,就会在这个线程的栈空间分配一块栈帧,用于存储这个方法运行时的一些局部变量表,操作数栈,动态链接,方法出口。没个方法都会对应自己独立的栈帧。
栈内放置栈帧的结构,与数据结构中的栈相同,First in Last out。按照程序调用顺序,后调用先执行。后分配的内存空间,会先被释放。
程序计数器:每个线程独有,用于存放下一行要执行的代码的对应内存位置或行号。每执行完一行代码,字节码执行引擎都会修改程序计数器的数值。
程序计数器作用:在多个线程并行的时候,会出现线程抢占CPU时间片的情况,当前线程会挂起。当从挂起恢复到运行态的时候,会从程序计数器中读取下一行要执行代码的位置,从而继续完成线程 。
操作数栈:存放当前要使用的操作数
动态链接:存放一些调用函数的内存位置。
方法出口:根据方法出口,确定当前函数执行完之后,下个函数要从哪开始执行。例如(Main调用了A函数,A函数执行完,需要从方法出口获取继续从Main函数的哪个位置调用)。
局部变量表:存放函数中的局部变量。如果局部变量为对象,则局部变量表中存储对象的地址。
方法区:常量池,存放常量+静态变量+类元信息
本地方法栈:用于存放native方法调用的dll库之类的类库所需要的内存。
堆:我们New出来的对象,一般会放在Eden区,当Eden存满之后,会先进行Minor gc,字节码执行引擎会启动一个gc线程,对Eden进行Minor gc。
STW: 一旦Old区满了,JVM就会触发FullGC,防止OOM,当触发FullGC的时候,JVM会停止所有用户线称的运行,执行GC线程。
为什么要设计STW:在GC的过程中,基本以Root为根节点进行可达性分析为基本原则,但如果不STW,用户线程还在执行和结束,会造成本来非垃圾的数据,在用户进程运行的过程中突然变成垃圾数据,从而导致GC清理永远没办法清理干净。
JVM内存参数。
Spring Boot程序的JVM参数设置格式(Tomcat启动直接加在bin目录下catalina.sh文件里): 1 java ‐Xms2048M ‐Xmx2048M ‐Xmn1024M ‐Xss512K ‐XX:MetaspaceSize=256M ‐XX:MaxMetaspaceSize=256M ‐jar microservice‐eurek a‐server.jar 关于元空间的JVM参数有两个:-XX:MetaspaceSize=N和 -XX:MaxMetaspaceSize=N -XX:MaxMetaspaceSize: 设置元空间最大值, 默认是-1, 即不限制, 或者说只受限于本地内存大小。 -XX:MetaspaceSize: 指定元空间触发Fullgc的初始阈值(元空间无固定初始大小), 以字节为单位,默认是21M,达到该值就会触发 full gc进行类型卸载, 同时收集器会对该值进行调整: 如果释放了大量的空间, 就适当降低该值; 如果释放了很少的空间, 那么在不超 过-XX:MaxMetaspaceSize(如果设置了的话) 的情况下, 适当提高该值。这个跟早期jdk版本的-XX:PermSize参数意思不一样。
如果一个War包 几百M甚至几G,启动要启动数分钟, 很有可能是没有设置方法区大小,它默认就是21M,然后每次满了就触发FullGC,相当浪费时间
XX:PermSize代表永久代的初始容量。 由于调整元空间的大小需要Full GC,这是非常昂贵的操作,如果应用在启动的时候发生大量Full GC,通常都是由于永久代或元空间发生 了大小调整,基于这种情况,一般建议在JVM参数中将MetaspaceSize和MaxMetaspaceSize设置成一样的值,并设置得比初始值要大, 对于8G物理内存的机器来说,一般我会将这两个值都设置为256M。
New方法执行过程(对象的创建流程)
1、类的检查:虚拟机在遇到一条New指令的时候,首先会去检查这个指令的参数能否在常量池定位到一个类的符号引用,并且判断这个类是否已经被加载,解析,初始化。如果没有,则执行类加载过程,如果有,则分配内存。
2、分配内存:类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存的大小在类加载后便可以完全确定。所以分配内存只存在两个问题:
1、如何划分内存
2、在并发情况下,可能出现正好给对象A分内存但指针还没修改,对象B又同时使用原来的指针分配内存的情况。
解决方法:
内存划分
1、指针碰撞(默认使用指针碰撞方法):如果JAVA堆中的内存是绝对规整的,那么我们新分配的对象只需要在空闲内存和已使用内存中间的分界点上划分出一块空间,并将分界点指针移动相同大小即可。
2、空闲列表:如果虚拟机上的堆内存是不连续的,那么就需要虚拟机维护一份空闲内存表,记录那些内存快是可用的,从而在列表中找到一块足够大的内存分配给对象。
并发问题
1、CAS:虚拟机采用CAS+失败重试 的方式来保证操作的原子性来对分配内存空间的动作进行同步处理。
2、本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)
把内存分配,按照每个线程,提前分配一块内存空间。线程创建的对象会首先向线程对应的内存空间中存储。JAVA8默认采用TLAB,可以通过-XX:+/-UseTLAB 来设定虚拟机是否使用TLAB,-XX:TLABSize指定TLAB大小。如果TLAB放不下,则继续走CAS。
初始化
对已经分配内存空间的变量,赋初值。
设置对象头
由于分代年龄只有四位,所以最高到15。这也就是为什么标记回收会标记15次才进入老年代。
KlassPointer(就是Klass,是JVM中C++类的指针)类指针(开启压缩占用4字节,关闭压缩占用8字节)指向类元信息(在方法区中)
Class类对象与类元信息的区别:类内的代码等信息,是作为类元信息放在方法区的,而Class对象 是JVM给予开发者的对象,为了让程序员获取类的相关信息。但是在JVM实际运行的时候,一般都会通过类型指针去找类元信息进行运行(实际上的类元信息,是C++对象存储的,提供JVM直接使用,而不是Class对象的那种JAVA对象)。
如果是数组对象的话,对象头还会存储数组长度。
Init
调用init方法(并不是构造方法), C++内部调用,会将赋初值的对象赋为具体值、
指针压缩
1.jdk1.6 update14开始,在64bit操作系统中,JVM支持指针压缩 2.jvm配置参数:UseCompressedOops,compressed压缩、oop(ordinary object pointer)对象指针 3.启用指针压缩:XX:+UseCompressedOops(默认开启),禁止指针压缩:XX:UseCompressedOops
如果不开启指针压缩,对象的对象头中,大量8bit的指针,会无形中占用大量的堆空间,提高GC的频率。JDK1.6之后,JVM默认开启指针压缩。
为什么要进行指针压缩?
1.在64位平台的HotSpot中使用32位指针,内存使用会多出1.5倍左右,使用较大指针在主内存和缓存之间移动数据, 占用较大宽带,同时GC也会承受较大压力 2.为了减少64位平台下内存的消耗,启用指针压缩功能 3.在jvm中,32位地址最大支持4G内存(2的32次方),可以通过对对象指针的压缩编码、解码方式进行优化,使得jvm只用32位地址就可以支持更大的内存配置(小于等于32G) 4.堆内存小于4G时,不需要启用指针压缩,jvm会直接去除高32位地址,即使用低虚拟地址空间 5.堆内存大于32G时,压缩指针会失效,会强制使用64位(即8字节)来对java对象寻址,这就会出现1的问题,所以堆内存不要大于32G为好
对象内存分配
并不是所有对象都直接分配在堆中。
对象分配到栈的情况
public User test1() {
User user = new User();
user.setId(1);
user.setName("zhuge");
//TODO 保存到数据库
return user;
}
public void test2() {
User user = new User();
user.setId(1);
user.setName("zhuge");
//TODO 保存到数据库
}
如上面函数test1,在test1中生成的对象,会return出去,有可能被其他函数所引用,所以这个对象逃逸出了这个方法。
test2方法中的user对象,并不会被test2函数之外的方法或对象引用,所以它并未逃逸出方法。
JVM可以将未逃逸出方法的对象,分配在栈帧内(对象不太大,栈帧内空间足够),栈帧内的对象会随着方法的结束而消亡,并不会一直存在与堆内,减轻了GC的压力。
开启逃逸分析参数:-XX:+DoEscapeAnalysis (JDK7之后默认开启)
标量替换:通过逃逸分析确定一个对象不会逃逸时,且栈帧内空间足够的情况下,JVM会试图将对象分配在栈帧内。但是栈帧内的内存不一定是连续的,而常规对象需要一个连续的内存空间。所以JVM会进行标量替换,即:将该对象分解成若干个被当前方法引用的成员变量,分配在栈帧内,从而合理利用碎片空间。
开启标量替换参数:-XX:+EliminateAllocations JDK7之后默认开启
对象在Eden分配
Eden与Survivor区默认8:1:1
如果大对象(-XX:PretenureSizeThreshold=1000000 通过这个参数修改,要与 -XX:+UseSerialGC 收集器配合使用),直接去Old。不是说除了SerialGC和ParNew之外,大对象就不去老年代,而是G1收集器等对老年代有自己的定义。
如果不是,则先进行TLAB分配(在线程独占的堆空间内分配),如果还是不行,就CAS分配。
为什么要设置大对象进老年代?
为了避免为大对象分配内存时的复制操作而降低效率。
对象动态年龄判断机制
做完MinorGC,如果放入survivor区的对象超过了Servier区50%,那么就会被直接放入Old。(如果一批对象,会按照年龄1+年龄2+。。。。+年龄N,如果刚好超过50%,就会把年龄N及以上(>=N)的放入老年代,其他的放入Survivor区。本质思想是让存活比较久的对象尽早进入Old。)
老年代分配担保机制:
在MinorGC之前,会触发老年代分配担保机制。