垃圾回收概述

垃圾回收概述

一、什么是垃圾

什么是垃圾( Garbage) 呢?

➢垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾。
如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占的内存空 间会一直保留到应用程序结束,被保留的空间无法被其他对象使用。甚至可能导致内存溢出。

  • 垃圾收集,不是Java语言的伴生产物。早在1960年,第一门开始使用内存动态分配和垃圾收集技术的Lisp语言诞生。
  • 关于垃圾收集有三个经典问题:
    • ➢哪些内存需要回收?
    • ➢什么时候回收?
    • ➢如何回收?
  • 垃圾收集机制是Java的招牌能力,极大地提高了开发效率。如今,垃圾收集几乎成为现代语言的标配,即使经过如此长时间的发展,Java的垃圾收集机制仍然在不断的演进中,不同大小的设备、不同特征的应用场景,对垃圾收集提出了新的挑战,这当然也是面试的热点。

二、为什么需要GC

  • 对于高级语言来说,一个基本认知是,如果不进行垃圾回收,内存迟早都会被消耗完,因为不断地分配内存空间而不进行回收,就好像不停地生产生活垃圾而从来不打扫一样。

  • 除了释放没用的对象,垃圾回收也可以清除内存里的记录碎片。碎片整理将所占用的堆内存移到堆的一端,以便JVM 将整理出的内存分配给新的对象。

  • 随着应用程序所应付的业务越来越庞大、复杂,用户越来越多,没有GC就不能保证应用程序的正常进行。而经常造成STW的GC又跟不上实际的需求,所以才会不断地尝试对GC进行优化。

三、早期的垃圾回收

  • 在早期的C/C++时代,垃圾回收基本.上是手工进行的。开发人员可以使用

new关键字进行内存申请,并使用delete关键字进行内存释放。比如以下代码:

1
2
3
4
MibBridge *pBridge = new cmBaseGroupBridge();
//如果注册失败,使用Delete释放该对象所占内存区域
if (pBridge->Register(kDestroy)!= NO_ERROR)
delete pBridge;
  • 这种方式可以灵活控制内存释放的时间,但是会给开发人员带来==频繁申请和释放内存的管理负担==。倘若有一处内存区间由于程序员编码的问题忘记被回收,那么就会产生内存泄漏,垃圾对象永远无法被清除,随着系统运行时间的不断增长,垃圾对象所耗内存可能持续上升,直到出现内存溢出并造成应用程序崩溃。
  • 在有了垃圾回收机制后,上述代码块极有可能变成这样:
1
2
MibBridge *pBridge = new cmBaseGroupBridge();
pBridge -> Register(kDestroy);
  • 现在,除了Java以外,C#、Python、 Ruby等语言都使用了自动垃圾回收的思想,也是未来发展趋势。可以说,这种自动化的内存分配和垃圾回收的方式己经成为现代开发语言必备的标准。

四、Java垃圾回收机制

  • 自动内存管理,无需开发人员手动参与内存的分配与回收,这样降低内存泄漏和内存溢出的风险

  • 自动内存管理机制,将程序员从繁重的内存管理中释放出来,可以更专心地专注于业务开发

  • 对于Java开发人员而言,自动内存管理就像是一个黑匣子,如果过度依赖于“自动”,那么这将会是一场灾难,最严重的就会弱化Java开发人员在程序出现内存溢出时定位问题和解决问题的能力。

  • 此时,了 解JVM的自动内存分配和内存回收原理就显得非常重要,只有在真正了解JVM是如何管理内存后,我们才能够在遇见OutOfMemoryError时, 快速地根据错误异常日志定位问题和解决问题。

  • 当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就必须对这些“自动化”的技术实施必要的监控和调节。

  • 垃圾回收器可以对年轻代回收,也可以对老年代回收,甚至是全堆和方法区的回收。

    • 其中,Java堆是垃圾收集器的工作重点。
    • 从次数上讲:
      • 频繁收集Young区
      • 较少收集0ld区
      • 基本不动Perm区(方法区)
  • image-20210703093933143

My New Post

文章已同步至GitHub开源项目: JVM底层原理解析

Java内存模型

JVM虚拟机规范中曾经试图定义一种Java内存模型,来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都可以达到一致性的内存访问效果。

然而定义这样一套内存模型并非很容易,这个模型必须足够严谨,才能让Java的并发内存访问操作不会有歧义。但是也必须足够宽松,这样使得虚拟机的具体实现能够有自由的发挥空间来利用各种硬件的优势。经过长时间的验证和弥补,到了JDK1.5(实现了JSR133规范)之后,Java内存模型才终于成熟起来了。

主内存和工作内存

Java内存模型规定了所有的变量都存储在`主内存`(Main Memory)中,每条线程都有自己的`工作内存`(Work Memory)
  • 工作内存中保存了被该线程使用的变量的主内存副本,
  • 线程对变量的读写操作必须在工作内存中进行。
  • 而不能直接访问主内存的数据。
  • 不同的线程也不能互相读写对方的工作内存,线程之间的变量传递必须通过主内存传递。

image-20210731210337786

主内存和工作内存的交互

Java内存模型定义了如下八种操作(每一种操作都是原子的不可再分的)

  • lock 锁定: 作用于主内存,将一个变量标识为线程独占状态
  • unlock: 解锁 : 作用于主内存,将一个线程独占状态的变量释放
  • read 读取 : 从主内存读取数据到工作内存,便于之后的load操作
  • load 载入: 把read读取操作从主内存中得到的变量放入工作内存的变量副本中
  • use 使用: 将工作内存中的变量传递给执行引擎 当虚拟机遇到一个需要使用变量值的字节码时,执行此操作
  • assign赋值: 将执行引擎中的值赋给工作内存的变量。 当虚拟机遇到一个赋值操作时,执行此操作
  • store存储: 将工作内存的值传递到主内存 ,便于之后的write操作
  • write写入:将store存储操作中从工作内存中获取的变量写入到主内存中

举例:

  • 如果要把一个变量从主内存拷贝到工作内存,则依次执行read读取操作, load载入操作
  • 如果要把一个变量从工作内存写入到主内存,则依次执行store存储操作,write写入操作

上述的8种操作必须满足以下规则:

  • 不允许read和load、store和write操作之一单独出现。也就是说不允许一个变量从主内存读取但是工作内存不接受,也不允许工作内存发起回写请求但是主内存不接受。
  • 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。
  • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
  • 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现
  • 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值
  • 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
  • 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。

volatile特殊规则

volatile可以说是Java虚拟机提供的最轻量级的同步机制。但是它并不容易被正确,完整的理解。

Java内存模型中规定

当一个变量被定义为`volatile`之后,表示着线程工作内存无效,对此值的读写操作都会直接作用在主内存上,

因此它具备对所有线程的立即可见性。以及禁止执行引擎对其进行指令重排序

  1. 保证此变量对所有线程的立即可见性

    当变量的值被修改之后,新值对于其他线程是立即可知的。普通变量并不能做到这一点,因为普通变量的值在线程之间的传递是要进过主内存来完成的。比如当线程A对变量进行了回写操作,线程B只有在A回写完成之后,在对主内存操作,新值才对B是可见的。在A回写到主内存的过程中,B读取的依旧是旧值。
    
    但是这并不可以推导出`基于volatile变量的运算在并发下是安全的`,因为在Java中的运算操作符并不是`原子性`的。这导致了`volatile变量在并发下运算是不安全`的。
    

    通过代码验证volatile变量在并发下运算是不安全

    首先我们创建20个线程,每个线程对volatile变量进行1000次的自增操作。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    /**
    * @作者: 写Bug的小杜 【email@shaoxiongdu.cn】
    * @时间: 2021/07/31
    * @描述: 通过代码验证 【volatile变量在并发下运算是不安全】
    */
    public class VolatileTest {

    //volatile修饰的count
    private static volatile int count = 0;

    //count自增方法
    public static void increment(){
    count++;
    }

    public static void main(String[] args) {

    //对count进行递增1000次操作的可运行接口
    Runnable runnable = new Runnable() {
    @Override
    public void run() {
    System.out.println(Thread.currentThread().getName() + "线程开始对count进行递增操作");
    for (int i = 0; i < 1000; i++) {
    increment();
    }
    System.out.println(Thread.currentThread().getName() + "线程对count递增操作结束");
    }
    };

    // 创建20个线程并启动
    for (int i = 0; i < 20; i++) {

    Thread thread = new Thread(runnable);
    thread.setName((i+1) + "号线程");
    thread.start();

    }

    while (Thread.activeCount() > 2){
    //主线程回到就绪状态
    Thread.yield();
    }

    System.out.println("所有线程结束,count = " + count);


    }


    }

    如果此程序在并发下是安全的,那么count的值最后肯定是20*1000 = 20000;也就是说,如果运行结果为20000,那么volatile变量在并发下运算是安全的

    通过多次运行程序,我们发现,count的值永远比20000小。

    image-20210731215601479

    那么,这是为什么呢?

    我们将上方的代码进行反编译,然后分析increment方法的字节码指令。

    1
    2
    3
    4
    5
    0 getstatic #2 <cn/shaoxiongdu/chapter6/VolatileTest.count : I>
    3 iconst_1
    4 iadd
    5 putstatic #2 <cn/shaoxiongdu/chapter6/VolatileTest.count : I>
    8 return

    我们可以发现,一行count++代码被分为 4行字节码文件去执行。通过对字节码的分析,我们发现,

    当偏移量为0的字节码getStatic将count的值从局部变量表取到操作数栈顶的时候,volatile保证了此时count的值是正确的,但是在执行iconst_1, iadd这些操作的时候,其他线程已经把count的值改变了,此时,操作数栈顶的count为过期的数据,所以putStatic字节码指令就有可能将较小的值同步到主内存中。因此最终的值会比20000稍微小。

    也就是说,volatile变量在并发下运算是不安全的

    在并发环境下,volatile的变量只是对全部线程即时可见的,如果要进行写的操作,还是要通过加锁来解决。

针对long和double类型变量的特殊规则

Java内存模型要求对上述的八种操作必须是原子性的,但是对于64位的数据类型(8字节的Long和double)在模型中特别定义了一条宽松的规定: 允许虚拟机将没有被volatile修饰的64位数据的读写划分为2次32位的操作来进行。

即允许虚拟机实现自行选择是否保证64位的数据类型的load,store,read 和wirte四个操作的原子性。这就是所谓的`long和double的非原子性协定`

原子性

由Java内存模型直接保证原子性变量操作包括: read,load,assign,use,store和write 6个。我们大致可以认为,基本数据类型的访问,读写操作都是原子性的。

如果场景需要一个更大范围的原子性保证,Java内存模型还提供了lock和unlock操作来满足,尽管虚拟机未把lock和unlock的操作直接开放给用户。但是提供了更高层次的字节码指令 `monitorenter`和`monitorexit`来隐式的使用这两个操作。这两个字节码指令反映到代码层面就是`synchronized`关键字。这就是为什么`synchronized`关键字内部的操作也是原子性的。

可见性

可加性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改,对修改是可见的。

Java内存模型中是以这种方式实现的: 在变量修改之后将修改的值同步回主内存,其他线程读取前将新值从主内存同步到工作内存。

无论是普通变量还是`volatile`变量都是如此。只不过`volatile`保证了修改了新值能立即同步到主内存,每次读取都会从主内存同步。

除了volatile之外,Java中还有两个关键字是可以保证可见性的。synchronizedfinal

synchronized

同步代码块可实现可见性是因为: 规定对一个变量执行unlock操作之前,必须把变量的值同步至主内存。也就是对一个变量进行解锁的时候,保证主内存中的值已经是最新的值。此时,解锁之后,别的线程从主内存进行同步,值就是正确的。

final

表示当final修饰的变量在构造器中一旦初始化完成,此时,不需要将`this`引用传递出去,别的线程就可以立即感知到final修饰的变量的值。

有序性

在Java中,如果在本线程内观察,所有的操作都是有序的。

如果在一个线程中观察另一个线程,则所有的操作都是无序的。

后半句主要是因为: `指令重排序`和`工作内存和主内存同步延迟`。

文章已同步至GitHub开源项目: JVM底层原理解析