JVM 内存模型与 GC 垃圾回收 目录一. 摘要二. JVM 简单介绍三. 线程私有的有哪些四. 线程共享的有哪些五. JVM 栈中程序是如何操作数据的六. 内存泄露是什么意思七. 堆内存的分配规则八. 判断垃圾对象算法8.1 引用计数法可达性分析法8.2 GC Roots 简述九. 垃圾回收算法9.1 垃圾回收机制简单概括9.2 标记清理算法9.3 标记整理算法9.4 复制算法9.5 新生代的GC9.6 哪些对象会存放在老年代9.7 老年代的GC一. 摘要Java 虚拟机的底层设计其实是非常复杂的本篇主要针对JVM的内存模型以及垃圾回收机制做一个简单概括和介绍明白它各种方法是如何运行的数据在哪里存放的以及垃圾回收都有哪些算法是如何使用的。二. JVM 简单介绍Java 虚拟机是运行在内存中的当我们的虚拟机拿到了自己可支配的内存之后它会对这些内存做分配大致分为五个区域分别为 栈(JVM栈)堆本地方法栈程序计数器方法区(在JDK8之后改名加元空间)。如下图所示JVM栈运行当前程序的地方我们定义的方法都是在JVM栈中跑起来的存储一些临时变量(局部变量)基本数据类型堆Java 虚拟机中内存最大的一块是被所有线程共享的几乎所有的对象(引用数据类型)实例都在这里分配内存本地方法栈主要存储 native 本地方法运行时的栈区用于管理本地方法的调用里面并没有我们写的代码逻辑其由native修饰由 C 语言实现程序计数器指向当前程序运行的位置它是一块很小的内存空间主要用来记录各个线程执行的字节码的地址例如分支、循环、线程恢复等都依赖于计数器方法区存储 static 静态方法类信息类加载器等一些全局数据信息三. 线程私有的有哪些在上述五部分之中JVM栈本地方法栈程序计数器这三个是每个线程私有的每个线程都会开辟自己的内存空间并将内存空间分为以上三部分。这三个也可以统称为栈所以有些人说JVM内存主要分为栈堆方法区就是这样来理解的。四. 线程共享的有哪些堆中存放的是对象方法区存放的是静态变量和静态方法这些都是全局共享的是所有线程共享的两块区域。例如 map 数组对象就存放在堆中所以在高并发场景下堆中的对象从内存的角度来看是线程不安全的因为可能有多个线程同时去操作一个 map 数组对象而JVM栈中的数组则是线程安全的因为私有不共享只有自己能使用。或许会有人会觉得我们的对象在JVM栈中不是也存在吗这样不是很矛盾吗其实这个是没有问题的因为在JVM栈中我们的对象实际存储的是堆中对象的内存地址程序实际操作对象时程序会根据存储的内存地址去对中获取到相应的对象进行操作。五. JVM 栈中程序是如何操作数据的刚才我说道我们自己编写的程序是运行在JVM栈中的而且程序中的方法有可能方法调用其他的方法层层循环。举个例子我们在程序中定义了一个方法A方法A中定义了 int 变量 x 10又调用了方法 B方法B定义了一个 int 变量 b 20又调用了方法C。那么在JVM栈中它的执行顺序就是1方法A进栈执行2程序向下执行发现 int 变量 a 10 然后 a 进栈3程序继续向下执行发现调用了方法B方法B进栈4继续执行发现定义了 int 变量 b 20 然后 b 进栈5继续执行方法C进栈6方法C执行完毕C出栈清除运行所申请的内存空间7方法B执行完毕int 变量 b 20 先出栈后方法B出栈清除运行所申请的内存空间8方法A执行完毕in 变量 a 10先出栈后方法A出栈清楚运行所申请的内存空间上面举的例子没有涉及引用数据类型但道理都是一样的唯一不同的点就是如果程序中操作修改了引用数据类型相关属性那么程序在运行时会根据内存地址去堆中修改相应的对象的属性。当方法执行完毕方法出栈由于对象存在于堆中所以程序对堆中对象做的修改被保存了下来堆中的对象并未因为程序执行完毕出栈而消亡而是一直存在于堆中。六. 内存泄露是什么意思现在我们已经知道了程序中创建和操作的对象都是存放在堆中的那么就会导致内存泄漏什么意思呢很简单我们的程序在JVM栈中运行的过程中可能会在堆中创建对象甚至创建很多对象但是当我们的程序结束后对象并不会被回收而是一直在堆中存在因为程序不能确定是否有其他的方法会继续操作使用当前对象而且有可能当前对象不止被一个方法引用所以不能删除这也是为什么对象是共享的原因之一。但是也有一些对象可能只在一个方法中有用到方法结束我们也不能将它删除它就会一直在堆中存在变成了没有用的垃圾对象随着程序不断的运行垃圾对象越来越多我们的内存就会逐渐被垃圾对象占满当我们再想创建新的对象的时候已经没有足够的内存就会出现内存溢出。而这些垃圾对象一直占用着我们的JVM内存无法被清理这一现象我们就称之为内存泄漏。为了防止内存泄漏JVM页就有了垃圾回收机制底层会利用复杂的算法和机制将没有引用的垃圾对象从堆中清除释放内存垃圾回收机制下面我会详细说到这里先提一嘴。七. 堆内存的分配规则堆中的对象也不是胡乱存放的。从上面的那幅图中也能看出在堆的内部它也是将堆内存进行的分配这里主要分为老年代和新生代。在新生代中它又分为了E区S0区S1区。我们新创建的对象通常会存放在E区新创建的对象就好比是新出生的婴儿要放在婴儿房中一样在这里就是把对象放在E区创建的对象也都是挨在一起的S0区和S1区是为了配合E区做垃圾回收而创建的老年代区就好比是老人已经活很久了在程序中指代多次垃圾回收机制都未被清除的对象多次都未被清除说明该对象很可能会长期存在所以直接从新生代移入老年代除了存放多了垃圾回收都未清除的对象一些大对象也会存放在老年代中。八. 判断垃圾对象算法8.1 引用计数法可达性分析法主要有两种算法分别是引用计数法和可达性分析算法。算法原理优点缺点JVM是否使用引用计数法每个对象维护一个计数器有引用就 1引用失效则 - 1弱引用不计数当计数器为0时表明当前对象无任何引用已经是垃圾对象简单实时无法解决循环引用问题A引用BB引用A但二者都五外界对象引用只被互相引用计数器不为0仍无法被标记为垃圾对象不可回收不适用可达性分析算法从一组 GC Roots 出发能到达的对象存活不可达的则回收解决循环引用需要STWStop The World标记期间用户线程全部暂停GC线程启动全力扫描计算垃圾对象JavaC#主流语言使用8.2 GC Roots 简述如下图所示GC Roots 根节点集合│┌─────────┼─────────┐↓ ↓ ↓对象 A ←── 对象 B 对象 C (栈帧中的局部变量)↑ ││ ↓└─── 对象 D ← 对象 E (静态变量引用)↓对象 F (可达存活)对象 G ←──→ 对象 H (互相引用但不可达 → 可回收)GC Roots 类型主要包含虚拟机栈引用静态变量引用常量引用JNI(Java Native Interface) Native 本地方法引用活跃线程同步监视器JVM内部引用。其中最最主要的是虚拟机栈引用静态变量引用常量引用这三个是最常见的根经过GC Roots 不可达标记后的对象会被初步判定为是可回收的垃圾对象但并不会马上被回收还会再进行一次筛选判断是否已经执行了 finalize() 方法。如果没有执行 finalize() 方法或 finalize() 方法已被执行则将当前对象回收否则放入队列等待执行如果在 finalize() 方法中重新与 GC Roots 建立关联变为GC Roots 可达对象可以自救但自救只能发生一次。实际开发过程中我们通常不会依赖 finalize() 方法因为不可靠且性能差。日常通过工具jmapMAT定位内存泄漏时也是基于可达性思想分析对象的 GC Roots 引用链。九. 垃圾回收算法9.1 垃圾回收机制简单概括堆中虽然存放了很多对象有些对象还是垃圾对象随着垃圾越来越多我们的内存就会越来越少导致我们的程序出现故障这种情况一定是不能出现的所以也就有了垃圾回收机制。它会利用不同的算法得出哪些对象需要被删除哪些对象需要被保留。垃圾回收机制是为了将那些没有引用的对象全部清除对于那些JVM栈中有直接或间接引用的对象本地方法站中有直接或间接引用的对象以及方法区(元空间中)有直接或间接引用的对象总之就是还在使用或可能要使用的对象都是不能被回收清除的。JVM内存模型中的垃圾回收机制远比我下面要说的复杂一些但为了方便理解我们就化繁为简讲出其核心运作原理。9.2 标记清理算法在此种算法情况下我么还没有对堆内存做出像上述图片那样的细致化区分这里直接称之为整个堆内存标记-清理算法就是通过特定的算法计算出那些没有引用已经是垃圾的对象对它们做标记然后JVM就会开启GC线程将所有打上标记的对象全部清除。但这种做法有一个缺点就是容易产生内存碎片什么意思呢刚才我说到了在新生代创建的对象一般都是挨在一起的如果我们隔一两个清除一个对象那么清除完垃圾对象之后虽然释放了内存空间得到的内存空间都是碎片化的不连贯的。例如我清理垃圾对象得到了1K的内存隔了一两个对象又清理出了1K的内存现在我要创建一个2K大小的对象是无法创建成功的因为这两个1K的内存空间不连贯我们也无法将这个2K的对象拆分成两份分开存储。清除垃圾对象之后得到了内存空间无法被合理利用就产生了内存碎片。9.3 标记整理算法在此种算法情况下我么还没有对堆内存做出像上述图片那样的细致化区分这里直接称之为整个堆内存由于标记-清理算法会产生内存碎片所以就有了另外一种算法标记-整理算法。也很好理解就是我们在经过标记-清理算法清除垃圾对象之后将所有的对象前部往前移动变换其所在位置填补上被清除掉的垃圾对象的内存位置类似List集合假设有10个元素我清除索引5号位的元素那么原本5号位之后的元素就要前部向前移动一位同样类似理解这样经过一番操作后就可以腾出更多更大的内存空间。但这种清理方法也有一种缺点就是代价太大了因为每清除一次垃圾对象很多保存下来的对象的位置都要进行反复的变化和移动对于内存开销太大了会比较耗时。9.4 复制算法在此种算法情况下堆内存被一分为二分为了两份标记-整理算法和标记-清理算法都有他们的缺点所以又有了另外一种垃圾回收算法——复制算法。它的原理是将内存一分为2将其中一半用来存放对象另一半先暂时不存当存储对象的那一半内存快要满的时候我们对那些要删除的对象做标记然后将不需要删除的对象挨个复制到另一半内存中去这样既避免了内存碎片的问题又不需要因为对象的移动而造成巨大的内存开销。但这个复制算法也有它自身的缺点就是我刚开始提到的它需要将内存一分为2如果想和原来保持不变那么我们就需要二倍的内存对内存大小要求较高。9.5 新生代的GC在文章刚开始我们提到了堆内存的分配规则而这个规则是在后来一步步演化而来现在我们知道堆堆内存主要分为新生代和老年代两大块。而新生代又具体分为E区S0区S1区。当我们程序中在进行 new 对象这一操作时实际上是在E区创建的对象。当E区对象快要把内存占满了之后就会触发新生代区域的GC。这里的新生代区域使用的是复制算法程序会对需要删除的对象做标记第一次会将不需要删除的对象紧凑的从E区复制到S0区将E区和S2区的对象全部删除第二次GC会把S0区和E区的有用对象复制到S1区然后将E区和S0区的对象全部删除第三次GC会重复上面的过程将S1区和E区的对象复制到S0区如此反复S1区和S2区交替工作。这里还有一个点注意一下E区S0区S1区他们三个的内存占比是811因为对象具有朝生夕死的特点大量对象通常在创建使用几次之后就没有作用了。所以初始创建时E区空间会留的大一些而幸存的对象或者说长时间使用的对象通常会很少S也是幸存(survive)的首字母所以S0区和S1区占比就会比较小。9.6 哪些对象会存放在老年代除去了新生代堆内存还有另外一个较大的内存空间就是老年代一个对象从新生代进入到老年代有以下三种可能的情况一个对象每经过一次GC年龄就会增加1。1在JVM虚拟机中如果一个对象在新生代经过15次GC垃圾回收都没有被删除次数可能会随着JDK版本变化那么就会把该对象从新生代直接转移到老年代因为15次GC都没有删除该对象说明该对象可能会长期存在没必要留在新生代反复参与GC因为GC需要复制幸存的对象到新区否则太消耗资源这个阈值的年龄也可以通过JVM的参数自行设置2还有就是如果一个对象占用的内存较大比如说一个容量 2000000(二百万) 大小的 Map 数组也会直接存放在老年代因为对象过大在参与新生代的复制算法时会非常消耗系统资源也需要直接放到老年代3除了上面说到的两种情况还有一种更为复杂的情况。S区中年龄从 1 到 n 的对象大小之和超过 S区的 50% 时新生代中年龄大于等于 n 的对象将进入老年代此外50%这个参数也是可以手动设置的默认是50%。这里要注意一点这个 n 的值并不是固定的而且是一个随机的值哦它只要满足从小到大累加计算就可以了所以 n 值是不确定的在《深入理解java虚拟机》这本书中是这样说的如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半年龄大于或等于该年龄的对象就可以直接进入老年代无须等到-XX MaxTenuringThreshold 中要求的年龄。9.7 老年代的GC刚才我说到了新生代区域的GC当然在老年代中也是有GC这么一个过程的。新生代使用的GC算法是复制算法而老年代则是使用的标记清理算法或标记整理算法这样有一点需要注意老年代的GC过程通常会伴随新生代一同进行也叫 FullGC在进行GC的期间Java程序会暂停全力的来进行垃圾回收。这里的话垃圾收集器就比较多了但它们采用的算法大多都是上面所列出的三种垃圾收集算法