垃圾收集机制与内存分配策略

Java 语言与其他编程语言有一个非常突出的特点,自动化内存管理机制。而这种机制离不开高效率的垃圾收集器(Garbage Collection)与合理的内存分配策略,这也是本篇文章将要描述的两个核心点。

引一句周志明老师对 Java 中的内存管理机制的描述:

Java 与 C++ 之间有一堵有内存动态分配和垃圾收集技术所围成的「高墙」,墙外面的人想进去,墙里面的人却想出来。

各有各的优势,没有谁会替代谁,只是应用在不同的场合下,谁更适合而已。

可达性分析算法

Java 中使用「可达性分析算法」来判定堆中的垃圾,但是很多其他的编程语言都采用「引用计数算法」判断对象是否依然存活。例如,Python,C++ 以及一些游戏脚本语言就采用的「引用计数算法」来判定对象的存活与否。

引用计数算法:给每一个引用对象增加一个计数器,每当有一个地方引用了该对象,就使该对象的计数器加一,每当一个引用失效时就使该计数器减一。当进行垃圾判定的时候,如果某个对象的计数器为零即说明了该对象无人引用,是垃圾。

这种算法设计简单,效率高,但 Java 里为什么没有采用呢?

主要是引用计数算法存在一个很致命的问题,循环引用。我们看一段代码:

public class A {private B bRef;public B getbRef() {return bRef;}public void setbRef(B bRef) {this.bRef = bRef;}
} 
public class B {private A aRef;public A getaRef() {return aRef;}public void setaRef(A aRef) {this.aRef = aRef;}
} 

产生循环引用:

public static void main(String[] args){A obj1 = new A();B obj2 = new B();obj1.setbRef(obj2);obj2.setaRef(obj1);obj1 = null;obj2 = null;
} 

他们的内存布局如下:

依照引用计数算法,栈中 obj1 对堆中 A 的对象有一个引用,因此计数器增一,obj2 对堆中 B 的对象有一个引用,计数器增一。然后这两个对象中的字段又互相引用了,各自的计数器增一。

然后我们让 obj1 和 obj2 分别失去对堆中的引用,按照常理来说,堆中的这两个对象已经无用了,应该被回收内存。但是你会发现,采用引用计数算法的程序语言不会回收这两个对象的内存空间,因为它们内部互相引用,计数器都不为零。

这就是「循环引用」问题,引用计数算法是无法辨别堆中的这两个对象已经无用了,所以程序中如果大量互相引用的代码,收集器将无法回收这部分无用的垃圾,即产生内存泄露问题。

但是,如果上述逻辑由 Java 语言实现,运行结果会告诉你,GC 回收了这部分垃圾。看看 GC 日志:

粗糙点来说,原先堆中的两个对象加上堆中一些其他对象总共占用了 2302K 内存空间,经过 GC 后,显然这两个对象所占的内存空间被释放了。

既然如此,那么 Java 采用的「可达性分析算法」是如何避免这一类问题的呢?

可达性分析算法:从「GC Roots」为起始点,遍历引用链,所有能够直接或者间接被「GC Roots」引用的对象都判定为存活,其他所有对象都将在 GC 工作时被回收。

那么这些根结点(GC Roots)的如何选择将直接决定了 GC 收集效率的高低。Java 中,规定以下的对象可以作为 GC Roots:

  • 虚拟机栈中引用的对象
  • 方法区中类属性引用的对象
  • 方法区常量引用的对象
  • 本地方法栈中 Native 方法引用的对象

整体上来看,这几种对象都是随时可能被使用的,不能轻易释放,或者说,这些对象的存活性极高,所以它们关联着的对象都不能被回收内存。

HotSpot 中可达性算法的实现

可达性分析的第一步就是枚举出所有的根结点(GC Roots),然后才能去遍历标记所有不可达对象。而实际上,HotSpot 的实现并没有按序枚举所有的虚拟机栈,方法区等区域进行根结点查找,而是使用了 OopMap 这种数据结构来实现枚举操作的。

堆中的每个对象在自己的类型信息中都保存有一个 OopMap 结构,记录了对象内引用类型的偏移量,也就是说,通过该对象可以得到该对象内部引用的所有其他对象的引用。

对于虚拟机栈来说,编译器会在每个方法的某些特殊位置使用 OopMap 记录当前时刻栈中哪些位置存放有引用。

于是 GC 在进行可达性分析的时候,无需遍历所有的栈和方法区,只需要遍历一下各个线程当前的 OopMap 即可完成根结点枚举操作,接着递归标记可达对象就行了。

理解了 HotSpot 是如何枚举根结点的,那么对于安全点这个概念就很好理解了,所有生成 OopMap 更新的位置就叫做安全点。当系统发起 GC 请求的时候,需要中断所有线程的活动,而并不是线程的任何状态下都适合 GC 的,必须在停下来之前完成 OopMap 的更新,这样会方便 GC 枚举跟结点。

所以,我们说线程收到中断请求的时候,需要「跑」到最近的安全点才能停下,这是因为安全点的位置会完成 OopMap 的更新,以保证各个位置的对象引用关系不再改变。(你想啊,GC 根据 OopMap 进行根结点枚举,离上一次 OopMap 你已经做了一大堆事情了,改变了栈上很多对象的引用关系,难道你在停下来被 GC 之前不应该把你所做的这些操作记录下来吗?不然 GC 哪知道哪些对象已经不用了,哪些对象你又重新引用了?)

那安全区域又是一个什么样的概念呢?

安全区域是指,一段代码的执行不会更改引用关系,这段代码所处的范围可以理解为一个区域,某个线程在这个区域中执行的时候,只要标志自己进入了安全区域,就不用理会系统发起的 GC 请求而可以继续运行。

程序离开安全区域之前,会检查系统是否已经完成了 GC 过程,如果没有则等待,否则「走」出安全区域,继续执行后续指令。

安全区域实际上是安全点的一个扩展,安全区域中运行的线程可以与 GC 垃圾收集线程并发工作,这是它最大的一个特点。

四大引用

Java 里的引用本质上类似于 C 语言中的指针,变量中的值是内存中另一块的地址,而并非实际的数据。Java 中有四种引用,它们各自有不同的生命范围。

  • 强引用,类似于 String s = new String(); 这类引用,s 就是一种强引用,只要 s 通过这种方式强引用堆中对象,GC 永远都不能回收被引用的对象的内存
  • 软引用,用于描述某些还有用但并非必必需的对象,某次 GC 操作后,如果内存还是不足以用于当前分配,也就是即将发生内存溢出,那么将回收所有软引用所占用的内存空间
  • 弱引用,用于描述一些非必需的对象引用,当垃圾收集器工作时,不论当前内存空间是否充足,都会回收这一部分内存空间
  • 虚引用,又称幽灵引用,这是一种最弱的引用,即便 GC 没有工作,我也无法拿到这类引用指向的对象了

除了强引用,其他的三类引用实际中很少使用,关于它们的测试代码,将随着本篇文章一起脱管在我的 GitHub 上,感兴趣的可以去 fork 回去运行一下,此处不再赘述。

垃圾收集算法

垃圾收集算法的实现是很复杂的,并且不同平台的虚拟机也有着不同的实现,但是单看收集算法本身而言,还是相对容易理解的。

标记-清除算法

标记清除算法实现思路包含两个阶段,第一个阶段,根据可达性分析算法标记所有不可达的「垃圾」,第二阶段,直接释放这些对象所占用的内存空间。

但是,它的缺点也很明显,做一次清除操作至少要遍历两次堆,一次用于标记,一次用于清除。并且整个堆内存会存在大量的内存碎片,一旦遇到大对象,将无法提供连续的内存空间而不得不提前触发一次 Full GC。

复制算法

复制算法将内存划分为两份大小相等的块,每次只使用其中的一块,当系统发起 GC 收集动作时,将当前块中依然存活的对象全部复制到另一块中,并整块的释放当前块所占内存空间。

这种算法不需要挨个去遍历清除,整体上释放内存,相对而言,效率是提高了,但是需要浪费一半的内存空间,有点浪费。

根据 IBM 公司的研究表明,「新生代」中的对象往往都是「朝生夕死」的,也就是说,我们完全没有必要舍掉一半的内存用于转移 GC 后存活的对象,因为活着的对象很少。

主流的商业虚拟机都采用复制算法对新生代进行垃圾收集,但是却将内存划分三个块,一块较大的 Eden 区和两块较小的 Survivor 区。

Eden 和 From 区域用于分配新生代对象的内存空间,当发生 Minor GC 的时候,虚拟机会将 Eden 和 From 中所有存活的对象全部移动到 To 区域并释放 Eden 和 From 的内存空间。

这样不仅解决了效率问题,也解决了空间浪费的问题,但是存在的问题是,如果不巧,某次 Minor GC 后,活着的对象很多,To 区放不下怎么办?

虚拟机的做法是,将这些对象往老年代晋升,具体的后文详细介绍。

标记-整理算法

标记整理算法一般用在老年代,它在标记清除算法的基础上,增加了一个步骤用于对将所有存活着的对象往一端移动以解决内存碎片问题。这种算法适用于老年代的垃圾回收,因为老年代的对象存活性高,每次只需要移动很少的次数即能完成垃圾的清理。

垃圾收集器

从可达性分析算法判定哪些对象不可达,标记为「垃圾」,到回收算法实现内存的释放操作,这些都是理论,而垃圾收集器才是这些算法的实际实现。虚拟机中使用不同的垃圾收集器收集不同分代中的「垃圾」,每种垃圾收集器都具有各自的特点,也适用于不同的场合,需要适时组合使用。但并不是任意的两个收集器都能组合工作的:

可以看到,新生代主要有三款收集器,老年代也有三款收集器,G1(Garbage First)是一款号称能一统所有分代的收集器,当然还不成熟。

收集器很多,本文限于篇幅不可能每一个都详细的介绍,只能简单的描述一下各个收集器的特点和优劣之处。

  • Serial:新生代的单线程垃圾收集器,适用于单 CPU,待收集内存不大的场景下,速度快高效率,是客户端模式下虚拟机首选的新生代收集器
  • ParNew:是 Serial 收集器的多线程版本,适用于多 CPU 多线程下的垃圾收集,是服务端虚拟机的首选收集器
  • Parallel Sacenge:类似于 ParNew,但却是一个注重吞吐量的收集器,可以显式指定收集器达到什么层次的吞吐量
  • Serial Old:Serial 的老年代版本,采用的标记整理算法收集垃圾
  • Parallel Old:Parallel 的老年代版本
  • CMS:这是一款基于标记清除算法收集新生代的收集器,主要特点是,低停顿时间,容易产生浮动垃圾

关于垃圾收集器的细节内容,很多,文章中不可能描述清楚,大家可以参阅相关书籍及论文进行学习。

内存分配策略

Java 对象的内存都分配在堆中,准确来说,新生的对象都分配在新生代的 Eden 区中,如果 Eden 区域不足以存放一些对象的时候,系统将发起一次 Minor GC 清除并复制依然存活的对象到 Survivor 区,一旦 Survivor 区域不够存放,将通过内存担保机制将这些对象移入老年代。下面我们用代码具体看一看:

//VM:-XX:+PrintGCDetails -Xms10M -Xmx10M -Xmn5M
//限制了 10M 的堆内存,其中新生代和老年代分别占 5M
byte[] buffer = new byte[2 * 1024 * 1024]; 

新生代收集器默认 Eden 与 Survivor 的比例为是 8:1。这里我们看到新生代已使用空间 4032K,其中一部分是我们两兆的字节数组,其余的是一些系统的对象内存分配。

如果我们还要再分配一兆大小的内存空间呢?

//VM:-XX:+PrintGCDetails -Xms10M -Xmx10M -Xmn5M
byte[] buffer = new byte[2 * 1024 * 1024];
byte[] buffer1 = new byte[1 * 1024 * 1024]; 

虚拟机首先会检查一下新生代还能不能再分出一兆的内存空间出来,发现不能,于是发起 MinorGC 回收新生代堆空间,并将依然存活的对象复制到另一块 Survivor 空间(to),发现 512K 根本放不下 buffer,于是通过担保机制将 buffer 送入老年代,接着为 buffer1 分配一兆的内存空间。

接着,我们来看看这个担保机制是怎样的?

当实际发生 MinorGC 之前,虚拟机会查看老年代最大可用的连续空间是否能容纳新生代当前所有对象,因为它假设此次 MinorGC 后,新生代所有对象都能够存活下来。

如果条件能够成立,虚拟机认为此次 GC 毫无风险,将直接进行 MinorGC 对新生代进行垃圾回收,否则虚拟机会去查看 HandlePromotionFailure 参数设置的值是否允许「担保失败」。

如果允许,那么虚拟机将继续判断老年代最大可用连续空间是否大于历届晋升过来的新生代对象的平均大小

如果大于,那么虚拟机将冒着风险去进行 MinorGC 操作,否则将改为一次 FullGC。

取平均值的这种概率方法能大概率的保证安全担保,但也不乏担保失败的情况出现,一旦担保失败,虚拟机将发起 FullGC 对整个堆进行扫描回收。看一段代码:

//VM:-XX:+PrintGCDetails -Xms10M -Xmx10M -Xmn5M
//系统对象占用大约 2M 堆空间
byte[] buffer = new byte[1 * 1024 * 1024];
byte[] buffer1 = new byte[1 * 1024 * 1024];
//此时新生代所剩下的空间大约 512K
byte[] buffer2 = new byte[1 * 1024 * 1024]; 

当我们的 buffer 和 buffer1 分配进 Eden 区之后,新生代剩下不足一兆的内存空间,但是当我们分配一个一兆的字节数组时,系统查看老年代空间为 5M 能够容纳新生代所有存活对象(4M 左右),于是直接发起 MinorGC,回收了新生代中部分对象并尝试着将活着的对象复制到 to 区块中。

显然,to 区域不能容纳这么多对象,于是全部晋升进入老年代。

接着为 buffer2 分配 1M 内存空间在 Eden 区,GC 日志如下:

可以看到,buffer 和 buffer1 已经被担保进入老年代了,而 buffer2 则被分配在了新生代中。MinorGC 之前,新生代中大约 4M 的对象在 MinorGC 后只剩下 504K 了,其中 2M 左右的对象被担保进入了老年代,还有一部分则被回收了内存。

总结一下,本篇文章介绍了虚拟机判定垃圾的「可达性分析算法」,几种垃圾回收算法,还简单的描述不同垃圾收集器各自的特点及应用场景。最后我们通过一些代码了解了虚拟机是如何分配内存给新生对象的。

总的来说,这只能算做一篇科普类文章,帮助你了解相关概念,其他的相关深入细节之处,还有待深入学习。


文章中的所有代码、图片、文件都云存储在我的 GitHub 上:

(https://github.com/SingleYam/overview_java)

欢迎关注微信公众号:扑在代码上的高尔基,所有文章都将同步在公众号上。

接下来我将给各位同学划分一张学习计划表!

学习计划

那么问题又来了,作为萌新小白,我应该先学什么,再学什么?
既然你都问的这么直白了,我就告诉你,零基础应该从什么开始学起:

阶段一:初级网络安全工程师

接下来我将给大家安排一个为期1个月的网络安全初级计划,当你学完后,你基本可以从事一份网络安全相关的工作,比如渗透测试、Web渗透、安全服务、安全分析等岗位;其中,如果你等保模块学的好,还可以从事等保工程师。

综合薪资区间6k~15k

1、网络安全理论知识(2天)
①了解行业相关背景,前景,确定发展方向。
②学习网络安全相关法律法规。
③网络安全运营的概念。
④等保简介、等保规定、流程和规范。(非常重要)

2、渗透测试基础(1周)
①渗透测试的流程、分类、标准
②信息收集技术:主动/被动信息搜集、Nmap工具、Google Hacking
③漏洞扫描、漏洞利用、原理,利用方法、工具(MSF)、绕过IDS和反病毒侦察
④主机攻防演练:MS17-010、MS08-067、MS10-046、MS12-20等

3、操作系统基础(1周)
①Windows系统常见功能和命令
②Kali Linux系统常见功能和命令
③操作系统安全(系统入侵排查/系统加固基础)

4、计算机网络基础(1周)
①计算机网络基础、协议和架构
②网络通信原理、OSI模型、数据转发流程
③常见协议解析(HTTP、TCP/IP、ARP等)
④网络攻击技术与网络安全防御技术
⑤Web漏洞原理与防御:主动/被动攻击、DDOS攻击、CVE漏洞复现

5、数据库基础操作(2天)
①数据库基础
②SQL语言基础
③数据库安全加固

6、Web渗透(1周)
①HTML、CSS和JavaScript简介
②OWASP Top10
③Web漏洞扫描工具
④Web渗透工具:Nmap、BurpSuite、SQLMap、其他(菜刀、漏扫等)

那么,到此为止,已经耗时1个月左右。你已经成功成为了一名“脚本小子”。那么你还想接着往下探索吗?

阶段二:中级or高级网络安全工程师(看自己能力)

综合薪资区间15k~30k

7、脚本编程学习(4周)
在网络安全领域。是否具备编程能力是“脚本小子”和真正网络安全工程师的本质区别。在实际的渗透测试过程中,面对复杂多变的网络环境,当常用工具不能满足实际需求的时候,往往需要对现有工具进行扩展,或者编写符合我们要求的工具、自动化脚本,这个时候就需要具备一定的编程能力。在分秒必争的CTF竞赛中,想要高效地使用自制的脚本工具来实现各种目的,更是需要拥有编程能力。

零基础入门的同学,我建议选择脚本语言Python/PHP/Go/Java中的一种,对常用库进行编程学习
搭建开发环境和选择IDE,PHP环境推荐Wamp和XAMPP,IDE强烈推荐Sublime;

Python编程学习,学习内容包含:语法、正则、文件、 网络、多线程等常用库,推荐《Python核心编程》,没必要看完

用Python编写漏洞的exp,然后写一个简单的网络爬虫

PHP基本语法学习并书写一个简单的博客系统

熟悉MVC架构,并试着学习一个PHP框架或者Python框架 (可选)

了解Bootstrap的布局或者CSS。

阶段三:顶级网络安全工程师

如果你对网络安全入门感兴趣,那么你需要的话可以点击这里👉网络安全重磅福利:入门&进阶全套282G学习资源包免费分享!

学习资料分享

当然,只给予计划不给予学习资料的行为无异于耍流氓,这里给大家整理了一份【282G】的网络安全工程师从入门到精通的学习资料包,可点击下方二维码链接领取哦。

  • 17
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
JVM内存结构: JVM内存分为如下五个部分: 1. 程序计数器 程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。每个线程都有一个程序计数器,是线程私有的,生命周期与线程相同。 2. Java虚拟机栈 Java虚拟机栈也是线程私有的,生命周期与线程相同。每个方法执行的时候,JVM都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。方法调用结束后,相应的栈帧也会被销毁。 3. 本地方法栈 本地方法栈也是线程私有的,它与Java虚拟机栈的作用非常相似,只不过它是为虚拟机使用到的Native方法服务。 4. JavaJava堆是JVM所管理的内存中最大的一块,也是所有线程共享的。Java堆是用于存储对象实例的内存区域,几乎所有的对象实例都在这里分配内存。Java堆是垃圾收集器管理的重点区域,也被称为GC堆。 5. 方法区 方法区也是线程共享的,用于存储已被JVM加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。在JDK8之前,永久代(PermGen)是方法区的一部分。在JDK8时,永久代被彻底移除,使用了元空间(Metaspace)来代替。 内存分配策略JVM内存分配策略主要有以下几种: 1. 对象优先在Eden区分配 当JVM需要为新的对象分配内存时,会优先在Eden区进行分配。如果Eden区没有足够的空间,JVM会通过Minor GC回收部分内存空间。 2. 大对象直接进入老年代 如果要分配的对象大小超过了Eden区的一半,JVM会直接将该对象分配到老年代。这样做的目的是为了避免在Eden区内产生大量的垃圾对象,从而降低了Minor GC的频率。 3. 长期存活的对象进入老年代 JVM会为每个对象定义一个年龄计数器,当一个对象在Eden区经历了一次Minor GC后仍然存活,会被移动到Survivor区。在Survivor区中,对象会被继续观察,如果其存活时间达到了一定的阈值,就会被晋升到老年代中。这样做的目的是为了保证长期存活的对象能够在老年代中有足够的空间进行分配。 4. 空间分配担保 每次进行Minor GC时,JVM都会检查老年代的可用空间是否足够,如果足够,就可以安全地将所有存活的对象晋升到老年代中。如果不足,JVM会检查这次Minor GC之前的晋升到老年代的对象的平均大小与老年代的剩余空间的比值,如果比值大于某个阈值(通常为50%),那么这次Minor GC就会中止,JVM会进行Full GC来释放一些空间。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值