24、【对线面试官】为什么需要Java内存模型

今天想跟你聊聊Java内存模型,这块你了解过吗?

  • 嗯,我简单说下我的理解吧。那我就从为什么要有Java内存模型开始讲起吧

    • 单线程下,可见性/有序性/原子性都没问题

    • CPU为了效率,有了高速缓存、有了指令重排序等等,整块架构都变得复杂了。我们写的程序肯定也想要「充分」利用CPU的资源啊!于是乎,我们使用起了多线程

      • 多线程在意味着并发,并发就意味着我们需要考虑线程安全问题

        • 1.缓存数据不一致:多个线程同时修改「共享变量」,CPU核心下的高速缓存是「不共享」的,那多个cache与内存之间的数据同步该怎么做?
        • 2.CPU指令重排序在多线程下会导致代码在非预期下执行,最终会导致结果存在错误的情况。
      • 针对于「缓存不一致」问题,CPU也有其解决办法,常被大家所认识的有两种:

        1.使用「总线锁」:某个核心在修改数据的过程中,其他核心均无法修改内存中的数据。(类似于独占内存的概念,只要有CPU在修改,那别的CPU就得等待当前CPU释放)

        2.缓存一致性协议(MESI协议,其实协议有很多,只是举个大家都可能见过的)。MESI拆开英文是(Modified(修改状态)、Exclusive(独占状态)、Share(共享状态)、Invalid(无效状态))

      • 缓存一致性协议我认为可以理解为「缓存锁」,它针对的是「缓存行」(Cache Iine)进行”加锁”,所谓「缓存行」其实就是高速缓存存储的最小单位。

      • MESI协议的原理大概就是:当每个CPU读取共享变量之前,会先识别数据的「对象状态」(是修改、还是共享、还是独占、还是无效)。

      • 如果是独占,说明当前CPU将要得到的变量数据是最新的,没有被其他CPU所同时读取

      • 如果是共享,说明当前CPU将要得到的变量数据还是最新的,有其他的CPU在同时读取,但还没被修改

      • 如果是修改,说明当前CPU正在修改该变量的值,同时会向其他CPU发送该数据状态为invalid(无效)的通知,得到其他CPU响应后(其他CPU将数据状态从共享(share)变成invalid(无效),会当前CPU将高速缓存的数据写到主存,并把自己的状态从modify(修改)变成exclusive(独占)

      • 如果是无效,说明当前数据是被改过了,需要从主存重新读取最新的数据。

      • 其实MESI协议做的就是判断「对象状态」,根据「对象状态」做不同的策略移动

      • 关键就在于某个CPU在对数据进行修改时,需要「同步」通知其他CPU,表示这个数据被我修改了,你们不能用了。

      • 比较于「总线锁」,MESI协议的”锁粒度”更小了,性能那肯定会更高咯

但据我了解,CPU还有优化,你还知道吗?

  • 同步,意味着等待,等待意味着什么都干不了。CPU肯定不乐意啊,所以又优化了一把。
  • 优化思路就是从「同步」变成「异步」。
  • 在修改时会「同步」告诉其他CPU,而现在则把最新修改的值写到「store buffe」中,并通知其他CPU记得要改状态,随后CPU就直接返回干其他事了。
  • 等到收到其它CPU发过来的响应消息,再将数据更新到高速缓存中。
  • 其他CPU接收到invalid(无效)通知时,也会把接收到的消息放入「invalid queue」中,只要写到「invalid queue.」就会直接返回告诉修改数据的CPU已经将状态置为「invalid」
  • 而异步又会带来新问题:那我现在CPU修改完A值,写到「store buffer」了,CPU就可以干其他事了
  • 那如果该CPU又接收指令需要修改A值,但上一次修改的值还在「store buffer.」中呢,没修改至高速缓存呢。
  • 所以CPU在读取的时候,需要去「storebuffer.」看看存不存在,存在则直接取,不存在才读主存的数据。【Store Forwarding】
  • 好了,解决掉第一个异步带来的问题了。(相同的核心对数据进行读写,由于异步,很可能会导致第二次读取的还是旧值,所以首先读「store buffer」。
  • 那当然啊,那「异步化」会导致相同核心读写共享变量有问题,那当然也会导致「不同」核心读写共享变量有问题啊
  • CPU1修改了A值,已把修改后值写到「store buffer.」并通知CPU2对该值进行invalid(无效)操作,而CPU2可能还没收到invalid(无效)通知,就去做了其他的操作,导致CPU2读到的还是旧值。
  • 即便CPU2收到了invalid(无效)通知,但CPU1的值还没写到主存,那CPU2再次向主存读取的时候,还是旧值…
  • 变量之间很多时候是具有「相关性」(a=1;b=0;b=a),这对于CPU又是无感知的.…
  • 总体而言,由于CPU对「缓存一致性协议」进行的异步优化「store buffer」「invalid queue.」,很可能导致后面的指令很可能查不到前面指令的执行结果(各个指令的执行顺序非代码执行顺序),这种现象很多时候被称作「CPU乱序执行」
  • 为了解决乱序问题(也可以理解为可见性问题,修改完没有及时同步到其他的CPU),又引出了「内存屏障」的概念。
  • 「内存屏障」其实就是为了解决「异步优化」导致「CPU乱序执行」/「缓存不及时可见」的问题,那怎么解决的呢?嗯,就是把「异步优化」给”禁用“掉
  • 内存屏障可以分为三种类型:写屏障,读屏障以及全能屏障(包含了读写屏障)
  • 屏障可以简单理解为:在操作数据的时候,往数据插入一条”特殊的指令”。只要遇到这条指令,那前面的操作都得「完成」。
  • 那写屏障就可以这样理解:CPU当发现写屏障的指令时,会把该指令「之前」存在于「store Buffer.」所有写指令刷入高速缓存。
  • 通过这种方式就可以让CPU修改的数据可以马上暴露给其他CPU,达到「写操作」可见性的效果。
  • 那读屏障也是类似的:CPU当发现读屏障的指令时,会把该指令「之前」存在于「invalid queue」所有的指令都处理掉
  • 通过这种方式就可以确保当前CPU的缓存状态是准确的,达到「读操作」一定是读取最新的效果。

聊了半天,我一直在讲硬件/操作系统的东西,我要回到正题上了。

  • 由于不同CPU架构的缓存体系不一样、缓存一致性协议不一样、重排序的策略不一样、所提供的内存屏障指令也有差异,为了简化Java开发人员的工作。Java封装了一套规范,这套规范就是「Java内存模型」
  • 再详细地说,「Java内存模型」希望屏蔽各种硬件和操作系统的访问差异,保证了Java程序在各种平台下对内存的访问都能得到一致效果。
  • 目的是解决多线程存在的原子性、可见性(缓存一致性)以及有序性问题。

那要不简单聊聊Java内存模型的规范和内容吧?

下次

总结

  • 并发问题产生的三大根源是「可见性」「有序性」「原子性」

  • 可见性:CPU架构下存在高速缓存,每个核心下的L1/L2高速缓存不共享(不可见)

  • 有序性:主要有三部分可能导致打破(编译器和处理器可以在不改变「单线程」程序语义的情况下,可以对代码语句顺序进行调整重新排序

    • 编译器优化导致重排序(编译器重排)
    • 指令集并行重排序(CPU原生重排)
    • 内存系统重排序(CPU架构下很可能有store buffer /invalid queue 缓冲区,这种「异步」很可能会导致指令重排)
  • 原子性:Java的一条语句往往需要多条 CPU 指令完成(i++),由于操作系统的线程切换很可能导致 i++ 操作未完成,其他线程“中途”操作了共享变量 i ,导致最终结果并非我们所期待的。

  • 在CPU层级下,为了解决「缓存一致性」问题,有相关的“锁”来保证,比如“总线锁”和“缓存锁”。

    • 总线锁是锁总线,对共享变量的修改在相同的时刻只允许一个CPU操作。
    • 缓存锁是锁缓存行(cache line),其中比较出名的是MESI协议,对缓存行标记状态,通过“同步通知”的方式,来实现(缓存行)数据的可见性和有序性
    • 但“同步通知”会影响性能,所以会有内存缓冲区(store buffer/invalid queue)来实现「异步」进而提高CPU的工作效率
    • 引入了内存缓冲区后,又会存在「可见性」和「有序性」的问题,平日大多数情况下是可以享受「异步」带来的好处的,但少数情况下,需要强「可见性」和「有序性」,只能”禁用”缓存的优化。
    • “禁用”缓存优化在CPU层面下有「内存屏障」,读屏障/写屏障/全能屏障,本质上是插入一条”屏障指令”,使得缓冲区(store buffer/invalid queue)在屏障指令之前的操作均已被处理,进而达到 读写 在CPU层面上是可见和有序的。
  • 不同的CPU实现的架构不一样,Java为了屏蔽硬件和操作系统访问内存的各种差异,提出了「Java内存模型」的规范,保证了Java程序在各种平台下对内存的访问都能得到一致效果。

l

25、【对线面试官】java从编译到执行,发生了什么

从基础先问起吧,你是怎么理解Java是一门「跨平台」的语言,也就是「一次编译,到处运行的」?

  • 因为有JVM
  • Java源代码会被编译为class文件,class文件是运行在JVM之上的。
  • 当我们日常开发安装JDK的时候,可以发现JDK是分「不同的操作系统」,JDK里是包含JVM的,所以Java依赖着JVM实现了『跨平台』
  • 通俗点来讲,JVM是面向操作系统的,它负责把Class字节码解释成系统所能识别的指令并执行,同时也负责程序运行时内存的管理。

那要不你来聊聊从源码文件(java)到代码执行的过程呗?

  • 简单总结的话,我认为就4个步骤:编译->加载->解释->执行

    • 编译:将源码文件编译成JVM可以解释的class文件。

      • 编译过程会对源代码程序做「语法分析」「语义分析」「注解处理」等等处理,最后才生成字节码文件。
      • 比如对泛型的擦除和我们经常用的Lombok就是在编译阶段干的。
    • 加载:将编译后的class文件加载到JVM中。

      • 在加载阶段又可以细化几个步骤:装载->连接->初始化

        • 【装载时机】为了节省内存的开销,并不会一次性把所有的类都装载至JVM,而是等到「有需要」的时候才进行装载(比如new和反射等等)
        • 【装载发生】class文件是通过「类加载器」装载到jvm中的,为了防止内存中出现多份同样的字节码,使用了双亲委派机制(它不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上)
        • 【装载规则】JDK中的本地方法类一般由根加载器(Bootstrp loader)装载,JDK中内部实现的扩展类一般由扩展加载器(ExtClassLoader)实现装载,而程序中的类文件则由系统加载器(AppClassLoader)实现装载。
      • 装载这个阶段它做的事情总结:查找并加载类的二进制数据,在JVM「堆」中创建一个java.lang.Class类的对象,并将类相关的信息存储在JVM「方法区」中

        • 通过「装载」这个步骤后,现在已经把class文件装载到JVM中了,并创建出对应的Class.对象以及类信息存储至方法区了。
      • 「连接」这个阶段它做的事情总结:对class的信息进行验证、为「类变量」分配内存空间并对其赋默认值。

        • 连接又可以细化为几个步骤:验证-》准备-》解析

          1.验证:验证类是否符合Java规范和JVM规范

          2.准备:为类的静态变量分配内存,初始化为系统的初始值

          3.解析:将符号引用转为直接引用的过程

        • 通过「连接」这个步骤后,现在已经对class信息做校验并分配了内存空间和默认值了。

      • 「初始化」阶段总结:为类的静态变量赋予正确的初始值。

        • 过程大概就是收集class的静态变量、静态代码块、静态方法至clinit()方法,随后从上往下开始执行。
        • 如果「实例化对象」则会调用方法对实例变量进行初始化,并执行对应的构造方法内的代码。
    • 解释:把字节码转换为操作系统识别的指令

      • 在解释阶段会有两种方式把字节码信息解释成机器指令码,一个是字节码解释器、一个是即时编译器(JIT)
      • JVM会对「热点代码」做编译,非热点代码直接进行解释。当JVM发现某个方法或代码块的运行特别频繁的时候,就有可能把这部分代码认定为「热点代码」
      • 使用「热点探测」来检测是否为热点代码。「热点探测」一般有两种方式,计数器和抽样。HotSpot使用的是「计数器」的方式进行探测,为每个方法准备了两类计数器:方法调用计数器和回边计数器
      • 这两个计数器都有一个确定的阈值,当计数器超过阈值溢出了,就会触发JIT编译。
      • 即时编译器把热点方法的指令码保存起来,下次执行的时候就无需重复的进行解释,直接执行缓存的机器语言
    • 执行:操作系统把解释器解析出来的指令码,调用系统的硬件执行最终的程序指令。

总结

  • Java跨平台因为有JVM屏蔽了底层操作系统

  • Java源码到执行的过程,从JVM的角度看可以总结为四个步骤:编译->加载->解释->执行

    • 「编译」经过 语法分析、语义分析、注解处理 最后才生成会class文件
    • 「加载」又可以细分步骤为:装载->连接->初始化。装载则把class文件装载至JVM,连接则校验class信息、分配内存空间及赋默认值,初始化则为变量赋值为正确的初始值。连接里又可以细化为:验证、准备、解析
    • 「解释」则是把字节码转换成操作系统可识别的执行指令,在JVM中会有字节码解释器和即时编译器。在解释时会对代码进行分析,查看是否为「热点代码」,如果为「热点代码」则触发JIT编译,下次执行时就无需重复进行解释,提高解释速度
    • 「执行」调用系统的硬件执行最终的程序指令

l

26、【对线面试官】双亲委派机制

接着上次的话题吧,要不你来详细讲讲双亲委派机制?

  • 上次提到了:class文件是通过「类加载器」装载至JVM中的
  • 为了防止内存中存在多份同样的字节码,使用了双亲委派机制(它不会自己去尝试加载类,而是把请求委托给父加载器去完成,依次向上)
  • JDK中的本地方法类一般由根加载器(Bootstrp loader)装载JDK中内部实现的扩展类一般由扩展加载器(ExtClassLoader)实现装载入而程序中的类文件则由系统加载器(AppClassLoader)实现装载。

java类加载结构图

打破双亲委派机制是什么意思?

  • 很好理解啊,意思就是:只要我加载类的时候,不是从App ClassLoader-》ExtClassLoader->BootStrap ClassLoader这个顺序找,那就算是打破了啊
  • 因为加载classi核心的方法在LoaderClass类的loadClass方法上(双亲委派机制的核心实现)
  • 那只要我自定义个ClassLoader,重写loadClass方法(不依照往上开始寻找类加载器),那就算是打破双亲委派机制了。

那你知道有哪个场景破坏了双亲委派机制吗?

  • tomcat
  • 部署项目时,会把war包放到tomcat的webapp下,这意味着一个tomcat可以运行多个Web应用程序
    • 那假设我现在有两个Web应用程序,它们都有一个类,叫做User,并且它们的类全限定名都一样,比如都是com.yyy.User。但是他们的具体实现是不一样的
    • 那么Tomcat是如何保证它们是不会冲突的呢?
  • 答案就是,那就是tomcat做了Web应用层级的隔离。Tomcat给每个Web应用创建一个类加载器实例(WebAppClassLoader),该加载器重写了loadClass方法,优先加载当前应用目录下的类,如果当前找不到了,才一层一层往上找

Tomcat还有哪些类加载器吗?

  • 并不是Web应用程序下的所有依赖都需要隔离的,比如Redis,因为如果版本相同,没必要每个Web应用程序都独自加载一份,就可以Web应用程序之间共享

    • 做法也很简单,Tomcat就在WebAppClassLoader.上加了个父类加载器(SharedClassLoader),如果WebAppClassLoader自身没有加载到某个类,那就委托SharedClassLoader去加载。
    • (无非就是把需要应用程序之间需要共享的类放到一个共享目录下,Share ClassLoader读共享目录的类就好了)
  • 为了隔绝Web应用程序与Tomcat本身的类,又有类加载器(CatalinaClassLoader)来装载Tomcat本身的依赖

  • 如果Tomcat本身的依赖和Web应用还需要共享,那么还有类加载器(CommonClassLoader)来装载进而达到共享

  • 各个类加载器的加载目录可以到tomcat的catalina.properties配置文件上查看

    Tomcat的类加载结构图
    ![](https://cdn.jsdelivr.net/gh/swimminghao/picture@main/img/Q0RM1Q_20211229140203.png)

JDBC你不是知道吗,听说它也是破坏了双亲委派模型的,你是怎么理解的?

  • JDBC定义了接口。具体实现类由各个厂商进行实现嘛(比如MySQL)

    • 类加载有个规则:如里一个类由类加载器A加载那么,这个类的依赖类也是由「相同的类加载器」加载。
    • 我们用JDBC的时候,是用DriverManager进而获取Connection,DriverManager在java.sql包下,显然是由BootStrap类加载器进行装载
    • 当我们使用DriverManager.getConnection()时,得到的一定是厂商实现的类.
    • 但因为这些实现类又不在java包中,BootStrap ClassLoaders并不能加载到各个厂商实现的类
  • DriverManager的解决方案就是,在DriverManager切始化的时候,得到「线程上下文加载器」

    • 获取Connection的时候,是使用「线程上下文加载器」去加载Connection的,而这里的线程上下文加载器实际上还是App ClassLoader
    • 所以在获取Connection的时候,还是先找ExtClassLoader和BootStrapClassLoader,只不过这两加载器肯定是加载不到的,最终会由AppClassLoader进行加载
  • 那这种情况,有的人觉得破坏了双亲委派机制,因为本来明明应该是由BootStrapClassLoader进行加载的,结果来了手「线程上下文加载器」,改掉了
    类加载器
  • 有的人觉得没破坏双亲委派机制,只是改成由「线程上下文加载器」进行类载,但还是遵守着:「依次往上找父类加载器进行加载,都找不到时才由自身加载」。认为“原则“上是没变的。

总结

前置知识:JDK中默认类加载器有三个:AppClassLoader、Ext ClassLoader、BootStrap ClassLoader。AppClassLoader的父加载器为Ext ClassLoader、Ext ClassLoader的父加载器为BootStrap ClassLoader。这里的父子关系并不是通过继承实现的,而是组合。

什么是双亲委派机制:加载器在加载过程中,先把类交由父类加载器进行加载,父类加载器没找到才由自身加载。

双亲委派机制目的:为了防止内存中存在多份同样的字节码(安全)

类加载规则:如果一个类由类加载器A加载,那么这个类的依赖类也是由「相同的类加载器」加载。

如何打破双亲委派机制:自定义ClassLoader,重写loadClass方法(只要不依次往上交给父加载器进行加载,就算是打破双亲委派机制)

打破双亲委派机制案例:Tomcat

  1. 为了Web应用程序类之间隔离,为每个应用程序创建WebAppClassLoader类加载器
  2. 为了Web应用程序类之间共享,把ShareClassLoader作为WebAppClassLoader的父类加载器,如果WebAppClassLoader加载器找不到,则尝试用ShareClassLoader进行加载
  3. 为了Tomcat本身与Web应用程序类隔离,用CatalinaClassLoader类加载器进行隔离,CatalinaClassLoader加载Tomcat本身的类
  4. 为了Tomcat与Web应用程序类共享,用CommonClassLoader作为CatalinaClassLoader和ShareClassLoader的父类加载器
  5. ShareClassLoader、CatalinaClassLoader、CommonClassLoader的目录可以在Tomcat的catalina.properties进行配置

线程上下文加载器:由于类加载的规则,很可能导致父加载器加载时依赖子加载器的类,导致无法加载成功(BootStrap ClassLoader无法加载第三方库的类),所以存在「线程上下文加载器」来进行加载。

l

27、【对线面试官】深入浅出Java内存模型

上一次已经问过了为什么要有Java内存模型

  • 答案是:Java为了屏蔽硬件和操作系统访问内存的各种差异,提出了「Java内存模型」的规范,保证了Java程序在各种平台下对内存的访问都能得到一致效果
  • 强调下:Java内存模型它是一种「规范」,Java虚拟机会实现这个规范。

先聊下Java内存模型的抽象结构?

  • Java内存模型定义了:Java线程对内存数据进行交互的规范。
    • 线程之间的「共享变量」存储在「主内存」中,每个线程都有自己私有的「本地内存」,「本地内存」存储了该线程以读/写共享变量的副本。
    • 本地内存是Java内存模型的抽象概念,并不是真实存在的。

  • Java内存模型规定了:线程对变量的所有操作都必须在「本地内存」进行,「不能直接读写主内存」的变量
    • Java内存模型定义了8种操作来完成「变量如何从主内存到本地内存,以及变量如何从本地内存到主内存」
    • 分别是read/load/use/assign/store/write/lock/unlock操作
    • 对变量一个读写操作就涵盖这些操作

happen-before规则

  • 按我的理解下,happen-before实际上也是一套「规则」。Java内存模型定义了这套规则,目的是为了阐述「操作之间」的内存「可见性」

    • 从上次讲述「指令重排」就提到了,在CPU和编译器层面上都有指令重排的问题。
  • 但:在某些重要的场景下,这一组操作都不能进行重排序,「前面一个操作的结果对后续操作必须是可见的」。

    • Java内存模型就提出了happen-before这套规则,规则总共有8条

      • 比如传递性、volatile变量规则、程序顺序规则、监视器锁的规则…
  • 有了happen-before这些规则。我们写的代码只要在这些规则下,前一个操作的结果对后续操作是可见的,是不会发生重排序的。

volatile内存语义

  • volatile是java的一个关键字

  • 特性:可见性和有序性(禁止重排序)

  • java内存模型这个规范,很大程度下就为了解决可见性和有序性的问题。

volatile是怎么做到可见性和有序性的

  • 为了实现volatile有序性和可见性,定义了4种内存屏障的「规范」,

  • 分别是LoadLoad/LoadStore/StroreLoad/StoreStrore

  • 本质上,就是在volatile前后加上了内存屏障,使得编译器和CPU无法进行重排序,致使有序,并且对volatile变量对其他线程可见

  • Hotspot虚拟机实现

    • 在「汇编」层面上实际是通过Lock前缀指令来实现的(lock支持大部分平台,而fence指令是x86平台的)
    • locK指令能保证:禁止CPU和编译器的重排序(保证了有序性)、保证CPU写核
      心的指令可以立即生效且其他核心的缓存数据失效(保证了可见性)。

volatile和MESl协议是啥关系?

  • 没有直接关联
  • Java内存模型关注的是编程语言层面上,它是高维度的抽象。
  • MESI是CPU缓存一致性协议,不同的CPU架构都不一样,可能有的CPU压根就没用MESI协议.
  • 只不过MESI名声大,大家就都拿他来举例子了。
  • MESI可能只是在「特定的场景下」为实现volatile的可见性/有序性而使用到的一部分罢了
  • 为了让Java程序员屏蔽上面这些底层知识,快速地入门使用volatile变量
  • Java内存模型的happen-before规则中就有对volatile变量规则的定义:对一个volatile变量的写操作相对于后续对这个volatile变量的读操作可见
  • 只要变量声明了volatile关键字,写后再读,读必须可见写的值。(可见性、有序性)

总结

为什么存在Java内存模型:Java为了屏蔽硬件和操作系统访问内存的各种差异,提出了「Java内存模型」的规范,保证了Java程序在各种平台下对内存的访问都能得到一致效果

Java内存模型抽象结构:线程之间的「共享变量」存储在「主内存」中,每个线程都有自己私有的「本地内存」,「本地内存」存储了该线程以读/写共享变量的副本。线程对变量的所有操作都必须在「本地内存」进行,而「不能直接读写主内存」的变量

happen-before规则:Java内存模型规定在某些场景下(一共8条),前面一个操作的结果对后续操作必须是可见的。这8条规则成为happen-before规则

volatile:volatile是Java的关键字,修饰的变量是可见性且有序的(不会被重排序)。可见性&&有序性,由Java内存模型定义的「内存屏障」完成,实际HotSpot虚拟机实现Java内存模型规范,汇编底层是通过Lock指令来实现。

l

28、【对线面试官】JVM内存模型

聊聊JVM的内存结构吧?

  • class文件会被类加载器装载至JVM中,并且JVM会负责程序「运行时」的「内存管理」
  • 而JVM的内存结构,往往指的就是JVM定义的「运行时数据区域」
  • 简单来说就分为了5大块:方法区、堆、程序计数器、虚拟机栈、本地方法栈

顺便讲下你这图上每个区域的内容

  • 程序计数器
    • Java是多线程的语言,假设线程数大于CPU数,就很会有「线程切換」现象,切换意昧着「中断」和「恢复」,那自然就需要有一块区域来保存「当前线程的执行信息」
    • 所以,程序计数器就是用于记录各个线程执行的字节码的地址(分支、循环跳转、异常、线程恢复等都依赖于计数器)
  • 虚拟机栈
    • 每个线程在创建的时候都会创建一个虚拟机栈,每次方法调用都会创建一个「栈帧」。每个「栈帧」会包含几块内容:局部变量表、操作数栈、动态连接和返回地址
    • 作用:它保存方法的局部变量、部分变量的计算并参与了方法的调用和返回。

  • 本地方法栈

    • 本地方法栈跟虚拟机栈的功能类似,虚拟机栈用于管理Java函数的调用,而本地方法栈则用于管理本地方法的调用。这里的「本地方法」指的是「非Java方法」,一般本地方法是使用C语言实现的。
  • 方法区

    • 前面提到了运行时数据区这个「分区」是JVM的「规范」,具体的落地实现,不同的虚拟机厂商可能是不一样的
    • 所以「方法区」也只是JVM中规范的一部分
    • Hotspot虚拟机,就会常常提到「永久代」这个词。 Hotspotl虚拟机在「JDK8前」用「永久代」实现了「方法区」,而很多其他厂商的虚拟机其实是没有「永久代」的概念的
    • 在JDK8中,已经用「元空间」来替代了「永久代」作为「方法区」的实现了
    • 方法区主要是用来存放已被虚拟机加载的「类相关信息」:包括类信息、常量池
      • 类信息又包括了类的版本、字段、方法、接口和父类等信息。
      • 常量池又可以分「静态常量池」和「运行时常量池」
        • 静态常量池主要存储的是「字面量」以及「符号引用」等信息,静态常量池也包括了我们说的「字符串常量池」。
        • 「运行时常量池」存储的是「类加载」时生成的「直接引用」等信息
        • 值得注意的是:从「逻辑分区」的角度而言「常量池」是属于「方法区」的
        • 但自从在「JDK7」以后,就已经把「运行时常量池」和「静态常量池」转移到了「堆」内存中进行存储
        • 对于「物理分区」来说「运行时常量池」和「静态常量池』就属于堆
      • 总体来说,就是逻辑分区和物理实际存储的位置,是不一样的
  • 堆

    • 「堆」是线程共享的区域,几乎类的实例和数组分配的内存都来自于它

    • 「堆」被划分为「新生代」和「老年代」,「新生代」又被进一步划分为Eden和 Survivor区,最后 Survivor由From Survivor 和 To Survivor组成

从「JDK8」已经把「方法区」的实现从「永久代」变成「元空间」,有什么区别?

  • 最主要的区别就是:「元空间」存储不在虚拟机中,而是使用本地内存,JVM不会再出现方法区的内存溢出,以往「永久代」经常因为内存不够用导致跑出OOM异常。
  • 按JDK8版本,总结起来其实就相当于:「类信息」是存储在「元空间」的(也有人把「类信息」这块叫做「类信息常量池」)
  • 而「常量池」用JDK7开始,从「物理存储」角度上就在「堆中」,这是没有变化的。

JVM内存结构和Java內存模型有啥区别吧?

  • Java内存模型是跟「并发」相关的,它是为了屏蔽底层细节而提出的规范,希望在上层(Java层面上)在操作内存时在不同的平台上也有相同的效果
  • JVM内存结构(又称为运行时数据区域),它描述着当我们的 class文件加载至虚拟机后,各个分区的「逻辑结构」是如何的,每个分区承担的作用

总结

JVM内存结构组成:JVM内存结构又称为「运行时数据区域」。主要有五部分组成:虚拟机栈、本地方法栈、程序计数器、方法区和堆。其中方法区和堆是线程共享的。虚拟机栈、本地方法栈以及程序计数器是线程隔离的。

l

29、【对线面试官】垃圾回收机制

聊聊Java的垃圾回收机制?

  • 我们使用Java的时候,会创建很多对象,但我们未曾「手动」将这些对象进行清除,而如果用C++语言的时候,用完是需要自己free(释放)掉的
  • 写Java的时候不用自己手动释放”垃圾”呢?原因很简单,JVM帮我们做了(自动回收垃圾)
  • 垃圾的定义:只要对象不再被使用了,那我们就认为该对象就是垃圾,对象所占用的空间就可以被回收·

是怎么判断对象不再被使用的呢?

  • 常用的算法有两个「引用计数法」和「可达性分析法」

    • 引用计数法思路很简单:当对象被引用则+1,但对象引用失败则-1。当计数器为0时,说明对象不再被引用,可以被可回收

    • 缺点就是:如果对象存在循环依赖,那就无法定位该对象,是否应该被回收(A依赖B,B依赖A)

    • 是可达性分析法:它从「GC Roots」开始向下搜索,当对象到「GC Roots」都没有任何引用相连时,说明对象是不可用的,可以被回收

      • 「 GC Roots」是一组必须「活跃」的引用

      • 从「 GC Root」出发,程序通过直接引用或者间接引用,能够找到可能正在被使用的对象

        • 比如:JVM内存结构中的虚拟机栈,虚拟机栈里的栈帧,栈帧中的局部变量,局部变量就存储着引用。
        • 那如果栈帧位于虚拟机栈的栈顶,是不是说明这个栈帧是活跃的(换言之,是线程正在被调用的)
        • 既然是线程正在调用的,那栈帧里的指向「堆」的对象引用,就一定是「活跃」的引用
        • 所以,当前活跃的栈帧指向堆里的对象引用就可以是「 GC Roots」
      • 当然了,能作为「 GC Roots」也不单单只有上面那一块

        • 比如类的静态变量引用是「 GC Roots」,被「Java本地方法」所引用的对象也是「 GC Roots」等等
      • 「 GC Roots」是一组必须「活跃」的「引用」,只要跟「GC Roots」没有直接或者间接引用相连,那就是垃圾。JVM用的就是「可达性分析算法」来判断对象是否为垃圾

标记完,怎么删除的(垃圾回收算法)

  • 标记清除
    • 缺点:直接清除会有「内存碎片」的问题:可能我有10M的空余内存,但程序申请9M内存空间却申请不下来(10M的内存空间是垃圾清除后的,不连续的)
  • 标记复制
    • 「标记」存活的对象「复制」到另一块空间,复制完了之后,直接把原有的整块空间给干掉!这样就没有内存碎片的问题了
    • 缺点:内存利用率低,得有一块新的区域给我复制(移动)过去
  • 标记整理
    • 当前区域内进行移动,存活对象一到一边,垃圾移到一边,再统一删除,就不会有内存碎片了

老年代、年轻代

  • 大部分对象的生命周期都很短,而只有少部分对象可能会存活很长时间
  • 回收垃圾的时候,程序是有短暂的时间不能正常继续运作啊。(JVM在回收的时候,用户线程不能继续分配修改引用),为了使「 stop the word」持续的时间尽可能短以及提高并发式GC所能应付的内存分配速率
  • 所以很多的垃圾收集器上都会在「物理」或者「逻辑」上,把这两类对象进行区分
    • 死得快的对象所占的区域叫做「年轻代」,活得久的对象所占的区域叫做「老年代」
    • 但也不是所有的「垃圾收集器」都会有,只不过我们现在线上用的可能都是JDK8,JDK8及以下所使用到的垃圾收集器都是有「分代」概念的

垃圾收集器

  • 垃圾回收的过程,其实就对应着几种「垃圾回收算法」分别是

    • 标记清除算法、标记复制算法和标记整理算法【「标记」「复制」「整理」】
  • 「年轻代」的垃圾收集器有: Seria、Parallel Scavenge、 Pardew

    • 年轻代的垃圾回收器使用的都是「标记复制算法」
    • 所以在「堆内存」划分中,将年轻代划分出 Survivor区( Survivor From和 ourvor To),目的就是为了有一块完整的内存空间供垃圾回收器进行拷贝(移动)
    • 新对象则放入Eden区
    • 堆内存大小默认比例:

  • 「老年代」的垃圾收集器有: Serial Old、 Parallel Old、CMS

  • Serial是单线程的, Parallel是多线程。这些垃圾收集器实际上就是「实现了」垃圾回收算法(标记复制、标记整理以及标记清除算法)

  • CMS是「JDK8之前」是比较新的垃圾收集器,它的特点是能够尽可能减少「stop the word」时间。在垃圾回收时让用户线程和GC线程能够并发执行」

新创建的对象一般是在「新生代」嘛,那在什么时候会到「老年代」中呢?

  • 两种情况
    • 如果对象太大了,就会直接进入老年代(对象创建时就很大 或者 Survivor区没办法存下该对象)
    • 如果对象太老了,那就会晋升至老年代(每发生一次 Monor GC,存活的对象年龄+1,达到默认值15则晋升老年代)或者(动态对象年龄判定可以进入老年代)

那 Monor GC什么时候会触发呢?

  • 当Eden区空间不足时,就会触发 Monor GC

那在「年轻代」GC的时候,从 GC Roots出发,那不也会扫描到「老年代」的对象吗?那那那.不就相当于全堆扫描吗?那这分代还有意义吗?

  • JVM解决方案

    • Hotspot虚拟机「老的GC」(G1以下)是要求整个GC堆在连续的地址空间上
    • 所以会有一条分界线(一侧是老年代,另一侧是年轻代),所以可以通过「地址」就可以判断对象在哪个分代上、
    • 当做 Monor GCI的时候,从 GC Roots出发,如果发现「老年代」的对象,那就不往下走了( Monor GC对老年代的区域毫无兴趣)

但又有个问题,那如果「年轻代」的对象被「老年代」引用了呢?(老年代对象持有年轻代对象的引用),那时候肯定是不能回收掉「年轻代」的对象的?

  • 解决方案
    • Hotspot虚拟机下有「 card table」(卡表)来避免全局扫描「老年代」对象
    • 「堆内存」的每一小块区域形成「卡页」,卡表实际上就是卡页的集合。当判断一个卡页中有存在对象的跨代引用时,将这个页标记为「脏页」
    • 那知道了「卡表」之后,就很好办了。每次 Monor GC的时候只需要去「卡表找到「脏页」,找到后加入至 GC Root,而不用去遍历整个「老年代」的对象了。

总结

什么是垃圾:只要对象不再被使用,那即是垃圾

如何判断为垃圾:可达性分析算法和引用计算算法,JVM使用的是可达性分析算法

什么是GC Roots:GC Roots是一组必须活跃的引用,跟GC Roots无关联的引用即是垃圾,可被回收

常见的垃圾回收算法:标记清除、标记复制、标记整理

为什么需要分代:大部分对象都死得早,只有少部分对象会存活很长时间。在堆内存上都会在物理或逻辑上进行分代,为了使「stop the word」持续的时间尽可能短以及提高并发式GC所能应付的内存分配速率。

Minor GC:当Eden区满了则触发,从GC Roots往下遍历,年轻代GC不关心老年代对象

什么是card table【卡表】:空间换时间(类似bitmap),能够避免扫描老年代的所有对象,进而顺利进行Minor GC (案例:老年代对象持有年轻代对象引用)

堆内存占比:年轻代占堆内存1/3,老年代占堆内存2/3。Eden区占年轻代8/10,Survivor区占年轻代2/10(其中From 和To 各站1/10)

l

#2、【对线面试官】今天来聊聊Java泛型

泛型了解

  1. 在Java中的泛型简单来说就是:在创建对象或调用方法的时候才明确下具体的类型
  2. 使用泛型的好处就是代码更加简洁(不再需要强制转换),程序更加健壮(在编译期间没有警告,在运行期就不会出现ClassCastException异常)

工作中用得多吗

  1. 在操作集合的时候,还是很多的,毕竟方便啊。List lists = new ArrayList<>();lists.add (”面试造火箭”);
  2. 如果是其他场景的话,那就是在写「基础组件」的时候了。

你是怎么写的

  1. 再明确一下泛型就是「在创建对象或调用方法的时候才明确下具体的类型」

  2. 而组件为了做到足够的通用性,是不知道「用户」传入什么类型参数进来的所以在这种情况下用泛型就是很好的实践。

  3. 这块可以参考SpringData JPA的JpaRepository写法。

    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
    public interface JpaRepository<T, ID> extends PagingAndSortingRepository<T, ID>, QueryByExampleExecutor<T> {

    List<T> findAll();

    List<T> findAll(Sort sort);

    List<T> findAllById(Iterable<ID> ids);

    <S extends T> List<S> saveAll(Iterable<S> entities);

    void flush();

    <S extends T> S saveAndFlush(S entity);

    void deleteInBatch(Iterable<T> entities);

    void deleteAllInBatch();

    T getOne(ID id);

    @Override
    <S extends T> List<S> findAll(Example<S> example);

    @Override
    <S extends T> List<S> findAll(Example<S> example, Sort sort);
    }
  4. 要写组件,还是离不开Java反射机制(能够从运行时获取信息),所以一般组件是泛型+反射来实现的。

  5. 回到我所讲的组件吧,背景是这样的:我这边有个需求,需要根据某些字段进行聚合。

  6. 换到SQL其实就是select sum(column 1),sum(column2) from table group by fie ld1,field2

  7. 需要sum和group by的列肯定是由业务方自己传入,而SQL的表其实就是我们的POJO(传入的字段也肯定是POJO的属性)

  8. 单个业务实际可以在参数上写死POJO,但为了做得更加通用,我把入参设置为泛型

  9. 拿到参数后,通过反射获取其字段具体的值,做累加就好了。

    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
    // 传入 需要group by 和 sum 的字段名
    public cacheMap(List<String> groupByKeys, List<String> sumValues) {
    this.groupByKeys = groupByKeys;
    this.sumValues = sumValues;
    }

    private void excute(T e) {

    // 从pojo 取出需要group by 的字段 list
    List<Object> key = buildPrimaryKey(e);

    // primaryMap 是存储结果的Map
    T value = primaryMap.get(key);

    // 如果从存储结果找到有相应记录
    if (value != null) {
    for (String elem : sumValues) {
    // 反射获取对应的字段,做累加处理
    Field field = getDeclaredField(elem, e);
    if (field.get(e) instanceof Integer) {
    field.set(value, (Integer) field.get(e) + (Integer) field.get(value));
    } else if (field.get(e) instanceof Long) {
    field.set(value, (Long) field.get(e) + (Long) field.get(value));
    } else {
    throw new RuntimeException("类型异常,请处理异常");
    }
    }

    // 处理时间记录
    Field field = getDeclaredField("updated", value);
    if (null != field) {
    field.set(value, DateTimeUtils.getCurrentTime());
    }
    } else {
    // group by 字段 第一次进来
    try {
    primaryMap.put(key, Tclone(e));
    createdMap.put(key, DateTimeUtils.getCurrentTime());
    }catch (Exception ex) {
    log.info("first put value error {}" , e);
    }
    }
    }
  10. 理解了泛型的作用之后,再去审视自己代码时,就可以判断是否需要用到泛型了。

价值体现

  1. 主要是在平时工作中,写代码的时候会多想想,遇到能用到的地方会优化下代码
l

30、【对线面试官】CMS垃圾回收器

今天还是来聊聊CMS垃圾收集器呗?

  • 如果用Seria和 Parallel系列的垃圾收集器:在垃圾回收的时,用户线程都会完全停止,直至垃圾回收结束!
  • CMS的全称: Concurrent Mark Sweep,翻译过来是「并发标记清除」

  • 用CMS对比上面的垃圾收集器( Seria和Parllel和 parNew):它最大的不同点就是「并发」:在GC线程工作的时候,用户线程「不会完全停止」,用户线程在「部分场景下」与GC线程一起并发执行
  • 无论是什么垃圾收集器, Stop The Word/是一定无法避免的!
  • CMS只是在「部分」的GC场景下可以让GC线程与用户线程并发执行
  • 目的:为了避免「老年代GC」出现「长时间」的卡顿( Stop The Word )

CMS工作流程

  • CMS可以简单分为5个步骤:初始标记、并发标记、并发预清理、重新标记以及并发清除

    • 从步骤可看出,CMS主要是实现了「标记清除」垃圾回收算法

    • 「初始标记」

      • 「初始标记」会标记 GCroots「直接关联」的对象以及「年轻代」指向「老年代」的对象
      • 「初始标记」这个过程是会发生 Stop The Word的。但这个阶段的速度算是很快的,因为没有「向下追溯」(只标记一层)

    • 「并发标记」

      • 「并发标记」这个过程是不会停止用户线程的(不会发生 Stop The Word)。这一阶段主要是从 GC Roots向下「追溯」,标记所有可达的对象
      • 并发标记」在GC的角度而言,是比较耗费时间的(需要追溯)

    • 「并发预处理」

      • 「并发预处理」这个阶段主要是:希望能减少下一个阶段「重新标记」所消耗的时间
      • 因为下一个阶段「重新标记」是需要Stop The World的,「并发标记」这个阶段由于用户线程是没有被挂起的,所以对象是有可能发生变化的
      • 可能有些对象,从新生代晋升到了老年代。可能有些对象,直接分配到了老年代(大对象)。可能老年代或者新生代的对象引用发生了变化

    • 「重新标记」

      • 「重新标记」阶段会 Stop The Word,这个过程的停顿时间其实很大程度上取决于上面「并发预处理」阶段
      • 这是一个追赶的过程:边在标记存活对象,一边用户线程在执行产生垃圾)

    • 「并发清除」

      • 一边用户线程在执行,一边GC线程在回收不可达的对象
      • 这个过程,还是有可能用户线程在不断产生垃圾,但只能留到下一次GC进行处
        理了,产生的这些垃圾被叫做“浮动垃圾”
      • 完了以后会重置CMS算法相关的内部数据,为下一次GC循环做准备

为什么要扫年轻代?

  • CMS主要回收老年代的对象。年轻代有可能会指向老年代的对象,不扫就不知道是不是垃圾了

「并发预处理」问题解决

  • 针对老年代的对象,其实还是可以借助类 card table的存储(将老年代对象发生变化所对应的卡页标记为 dirty)
  • 所以「并发预处理」这个阶段会扫描可能由于「并发标记」时导致老年代发生变化的对象,会再扫描一遍标记为diy的卡页
  • 对于新生代的对象,我们还是得遍历新生代来看看在「并发标记」过程中有没有对象引用了老年代.
  • JVM里给我们提供了很多「参数」,有可能在这个过程中会触发一次minor GC(触发了 minor GC是意味着就可以更少地遍历新生代的对象)

相比G1,那你觉得CMS有什么缺点呢?

  • 1.空间需要预留:CMS垃圾收集器可以一边回收垃圾,一边处理用户线程,那需要在这个过程中保证有充足的内存空间供用户使用。

    • 如果CMS运行过程中预留的空间不够用了,会报错( Concurrent Mode Failure),这时会启动 Serial Old垃圾收集器进行老年代的垃圾回收,会导致停顿的时间很长
  • 2.内存碎片问题:CMS本质上是实现了「标记清除算法」的收集器(从过程就可以看得出),这会意味着会产生内存碎片

    • 由于碎片太多,又可能会导致内存空间不足所触发 full GC,CMS一般会在触发full GC这个过程对碎片进行整理
    • 整理涉及到「移动」/「标记」,那这个过程肯定会 Stop The Word的,如果内存足够大(意味着可能装载的对象足够多),那这个过程卡顿也是需要一定的时间的。
  • 使用CMS的弊端好像就是一个死循环

    • 1.内存碎片过多,导致空间利用率减低。
    • 2.空间本身就需要预留给用户线程使用,现在碎片内存又加剧了空间的问题,导致有可能垃圾收集器降级为 Serial old,卡顿时间更长
    • 3.要处理内存碎片的问题(整理),同样会卡顿

总结

  • CMS把垃圾回收的过程给”细分”了,然后在某些阶段可以不停止用户线程,一边回收垃圾,一边处理请求,来减少每次垃圾回收时 Stop The Word的时间

  • 中间也做了很多的优化( dirty card标记、可能中途触发 minor gca等等,在我理解下,这些都提供了CMS的相关参数配置

  • CMS垃圾回收器设计目的:

    • 为了避免「老年代 GC」出现「长时间」的卡顿(Stop The World)
  • CMS垃圾回收器回收过程:

    • 初始标记、并发标记、并发预处理、重新标记和并发清除。初始标记以及重新标记这两个阶段会Stop The World
  • CMS垃圾回收器的弊端:

    • 会产生内存碎片&&需要空间预留:停顿时间是不可预知的

l

31、【对线面试官】G1垃圾收集器

要不这次来聊聊G1垃圾收集器?

  • CMS垃圾收集器的升级

  • G1垃圾收集器可以给你设定一个你希望Stop The Word停顿时间,G1垃圾收集器会根据这个时间尽量满足你

    • 在前面我在介绍JM堆的时候,堆的内存分布是以「物理」空间进行隔离

    • 在G1垃圾收集器的世界上,堆的划分不再是「物理」形式,而是以「逻辑」的形式进行划分

    • 不过的「分代」概念在G1垃圾收集器的世界还是一样奏效的

    • 比如说:新对象一般会分配到Eden区经过默认15次的 Minor GC新生代的对象如果还存活,会移交到老年代等等。

    • 堆被划分了多个同等份的区域,在G1里每个区域叫做Region

    • G1中,还有一种叫 Humongous(大对象)区域,其实就是用来存储特别大的对象(大于 Region内存的一半)

    • 一旦发现没有引用指向大对象,就可直接在年轻代的 Minor GC中被回收掉

    • 之所以要将「堆空间」进行「细分」多个小的区域,是因为像以前的垃圾收集器都是对堆进行「物理」划分,如果堆空间(内存)大的时候,每次进行「垃圾回收」都需要对一整块大的区域进行回收,那收集的时间是不好控制的;而划分多个小区域之后,那对这些「小区域」回收就容易控制它的「收集时间」了

GC过程

  • 在G1收集器中,可以主要分为有Minor GC( Young GC)和 Mixed GC,也有些特殊场景可能会发生 Full GC

    • Minor GC

      • G1的 Minor GC其实触发时机跟前面提到过的垃圾收集器都是一样的

      • 等到Eden区满了之后,会触发 Minor GC。 Minor GCI同样也是会发生 Stop The World的

      • 要补充说明的是:在G1的世界里,新生代和老年代所占堆的空间是没那么固定的(会动态根据「最大停顿时间」进行调整)

      • 这块会给我们提供参数进行配置就好了

      • 所以,动态地改变收集年轻代 Region的个数可以「控制」 Minor GCI的开销

      • Minor GC我认为可以简单分为为三个步骤:根扫描、更新&&处理RSet、复制对象

        1)第一步应该很好理解,因为这跟之前CMS是类似的,可以理解为初始标记的过程

        2)第二步就是处理RSet的信息并且扫描,将老年代对象持有年轻代对象的相关引用都加入到 GC Roots下,避免被回收掉

        ​ 涉及到「Rset」的概念

        ​ (1)上ー次我们聊CMS回收过程的时候,同样讲到了 Minor GC,它是通过「卡表」( cart table)来避免全表扫描老年代的对象

        ​ (2)因为 Minor GC是回收年轻代的对象,但如果老年代有对象引用着年轻代,那这些被老年代引用的对象也不能回收掉

        ​ (3)同样的,在G1也有这种问题(毕竟是Minor GC)。CMS是卡表,而G1解决「跨代引用」的问题的存储一般叫做RSet

        ​ (4)只要记住,RSet这种存储在每个 Region都会有,它记录着「其他 Region引用了当前 Regiong的对象关系」

        ​

        ​ (5)对于年轻代的 Region,它的RSet只保存了来自老年代的引用(因为年轻代的没必要存储啊,自己都要做 Minor GC了

        ​ (6)而对于老年代的 Region来说,它的RSet也只会保存老年代对它的引用(在G1垃圾收集器,老年代回收之前,都会先对年轻代进行回收,所以没必要保存年轻代的引用)

        3)第三步:把扫描之后存活的对象往「空的 Survivor区」或者老年代」存放,其他的Eden区进行清除

        ​ (1)这里要提下的是,在G1还有另一个名词,叫做CSet

        ​ (2)它的全称是 Collection Set,保存了一次GC中「将执行垃圾回收」的 Region。CSet中的所有存活对象都会被转移到别的可用 Region上

        ​ (3)在 Minor GC的最后,会处理下软引用、弱引用、 JNI Weak等引用,结束收集

  • 总结

    • 总结起来就是:扫描、处理跨 Region引用、收集至CSet、复制清除、处理引用

MixedGC过程

  • 当堆空间的占用率达到一定阈值后会触发 Mixed GC(默认45%,由参数决定)
  • Mixed GC会依赖「全局并发标记」统计后的 Region数据
  • 「全局并发标记」它的过程跟CMS非常类型,步骤大概是:初始标记(STW)、并发标记、最终标记(ST)以及清理(ST)
    • 说明: Mixed GC它一定会回收年轻代,并会采集部分老年代的Region进行回收的,所以它是一个混合GC
    • 「初始标记」,
      • 这个过程是「共用」了 Minor GC的 Stop The World(Mixed GC一定会发生 Minor GC),复用了「扫描 GC Roots的操作
      • 在这个过程中,老年代和新生代都会扫
      • 总的来说,「初始标记」这个过程还是比较快的,毕竟没有追溯遍历嘛
    • 「并发标记」
      • 这个阶段不会 Stop The World,GC线程与用户线程一起执行,GC线程负责收集各个 Region的存活对象信息
      • 从 GC Roots往下追溯,査找整个堆存活的对象,比较耗时
    • 「重新标记」
      • 跟CMS又一样,标记那些在「并发标记」阶段发生变化的对象
      • CMS在「重新标记」阶段,应该会重新扫描所有的线程栈和整个年轻代作为root,G1不是
        • 在G1中解決「并发标记」阶段导致引用变更的问题,使用的是SATB算法
        • 可以简单理解为:在GC开始的时候,它为存活的对象做了一次「快照」
        • 在「并发阶段」时,把每一次发生引用关系变化时旧的引用值给记下来
        • 然后在「重新标记」阶段只扫描着块「发生过变化」的引用,看有没有对象还是存活的,加入到「 GC Roots」上
        • 不过SATB算法有个小的问题,就是:如果在开始时,G1就认为它是活的,那就在此次GC中不会对它回收,即便可能在「并发阶段」上对象已经变为了垃圾。
        • 所以,G1也有可能会存在「浮动垃圾」
        • 但是总的来说,对于G1而言,问题不大(毕竟它不是追求一次把所有的垃圾都清除掉,而是注重 Stop The Worlde时间)
    • 「清理」
      • 这个阶段也是会 Stop The World的,主要清点和重置标记状态,会根据「停顿预模型」(其实就是设定的停顿时间),来决定本次GC回收多少 Region
      • 一般来说, Mixed GC会选定所有的年轻代 Region,部分「回收价值高」的老年代 Region(回收价值高其实就是垃圾多)进行采集
      • 最后 Mixed GC进行清除还是通过「拷贝」/「复制」的方式去干的
      • 所以在G1中,一次回收未必是将所有的垃圾进行回收的,G1会依据停顿时间做出选择 Region数量

什么时候发生full GC

  • 如果在 Mixed GC中无法跟上用户线程分配内存的速度,导致老年代填满无法继续进行 Mixed GC,就又会降级到 serial oldGC来收集整个 GC heap
  • 其实跟CMS是非常类似的都是因为空间不足
  • 不过uGC这个场景相较于CMS还是很少的,毕竟G1没有像CMS「内存碎片」这种问题

G1垃圾收集器特点:

  • 从原来的「物理」分代,变成现在的「逻辑」分代,将堆内存「逻辑」划分为多个Region
  • 使用CSet来存储可回收Region的集合
  • 使用RSet来处理跨代引用的问题(注意:RSet不保留 年轻代相关的引用关系)
  • G1可简单分为:Minor GC 和Mixed GC以及Full GC
  • 【Eden区满则触发】Minor GC 回收过程可简单分为:(STW) 扫描 GC Roots、更新&&处理Rset、复制清除
  • 全局并发标记的过程跟CMS过程差不多:初始标记(STW)、并发标记、最终标记(STW)以及清理(STW)
  • 【整堆空间占一定比例则触发】Mixed GC 依赖「全局并发标记」,得到CSet(可回收Region),就进行「复制清除」
  • 使用SATB算法来处理「并发标记」阶段对象引用存在变更的问题
  • 亮点&&重点:提供可停顿时间参数供用户设置(G1会尽量满足该停顿时间来调整 GC时回收Region的数量)
  • R大描述G1原理的时候,他提到:从宏观的角度看G1,主要分为两块「全局并发标记」和「拷贝存活对象」
l

33、【对线面试官】Redis主从架构

要不你来讲讲你公司的Redis是什么架构的咯?

  • 我前公司的Redis架构是「分片集群」,使用的是「Proxy」层来对Key进行分流到不同的Redis服务器上
  • 支持动态扩容、故障恢复等等…

那你来聊下Proxy.层的架构和基本实现原理?

  • 抱歉,这块由中间件团队负责,具体我也没仔细看过

  • 不过,我可以给你讲讲现有常见开源的Redis架构

    • 在之前提到了Redis有持久化机制,即便Redis重启了,可以依靠RDB或者AOF文件对数据进行重新加载

    • 但在这时,只有一台Redis服务器存储着所有的数据,此时如果Redis服务器「暂时」没办法修复了,那依赖Redis的服务就没了

    • 所以,为了Redis「高可用」,现在基本都会给Redis做「备份」:多启一台Redis服务器,形成「主从架构」

    • 「从服务器」的数据由「主服务器」复制过去,主从服务器的数据是一致的

    • 如果主服务器挂了,那可以「手动」把「从服务器」升级为「主服务器」,缩短不可用时间

那「主服务器」是如何把自身的数据「复制」给「从服务器」的呢?

  • 「复制」也叫「同步」,在Redis使用的是「PSYNC」命令进行同步,该命令有两种模型:完全重同步和部分重同步
  • 可以简单理解为:如果是第一次「同步」,从服务器没有复制过任何的主服务器,或者从服务器要复制的主服务器跟上次复制的主服务器不一样,那就会采用「完全重同步」模式进行复制
  • 如果只是由于网络中断,只是「短时间」断连,那就会采用「部分重同步」模式进行复制
  • (假如主从服务器的数据差距实在是过大了,还是会采用「完全重同步」模式进行复制)

同步原理

  • 主服务器要复制数据到从服务器,首先是建立Socket「连接」,这个过程会干一些信息校验啊、身份校验啊等事情

  • 然后从服务器就会发「PSYNC」命令给主服务器,要求同步(这时会带「服务器ID」RUNID和「复制进度」offset参数。如果从服务器是新的,那就没有)

  • 主服务器发现这是一个新的从服务器(因为参数没带上来),就会采用「完全重同步」模式,并把「服务器ID」(runld)和「复制进度」(offset)发给从服务器,从服务器就会记下这些信息。

  • 随后,主服务器会在后台生成RDB文件,通过前面建立好的连接发给从服务器从服务器收到RDB文件后,首先把自己的数据清空,然后对RDB文件进行加载恢复

  • 这个过程中,主服务器也没闲着(继续接收着客户端的请求)

  • 主服务器把生成RDB文件「之后修改的命令」会用「ouffer.」记录下来,等到从服务器加载完RDB之后,主服务器会把「buffer.」记录下的命令都发给从服务器

  • 这样一来,主从服务器就达到了数据一致性了(复制过程是异步的,所以数据是『最终一致性』)

那「部分重同步」的过程呢?

  • 嗯,其实就是靠「offset」来进行部分重同步。每次主服务器传播命令的时候,都会把「offset」给到从服务器

  • 主服务器和从服务器都会将「offset」保存起来(如果两边的offset存在差异,那么说明主从服务器数据未完全同步)

  • 从服务器断连之后进行重连,就会发「PSYNC」命令给主服务器,同样也会带着RUNID和offset(重连之后,这些信息还是存在的)

  • 主服务器收到命令之后,看RUNID是否能对得上,对得上,说明这可能以前就同步过一部分了

  • 接着检查该「offset在主服务器里还是否存在(主服务器记录主从服务器offset的信息用的是环形buffer,如果该ouffer)满了,会覆盖以前的记录。而记录客户端的修改命令用的是另一个buffer)

  • 如果从backlog_buffer找到了,那就把从缺失的一部分offer开始,把对应的修改命令发给从服务器

  • 如果从环形ouffer(backlog._buffer)没找到,那只能使用「完全重同步」模式再次进行主从复制了

    • 懂了,无非就是有个关联关系记录下来,只不过存储是环形(可能会造成覆盖)

Redis主库如果挂了,你还是得「手动」将从库升级为主库啊?你知道有什么办法能做到「自动」进行故障恢复吗?

  • 哨兵

    • 「哨兵」干的事情主要就是:监控(监控主服务器的状态)、选主(主服务器挂了,在从服务器选出一个作为主服务器)、通知(故障发送消息给管理员)和配置(作为配置中心,提供当前主服务器的信息)

    • 可以把「哨兵」当做是运行在「特殊」模式下的Redis服务器,为了「高可用」,哨兵也是集群架构的。

    • 首先它需要跟Redis主从服务器创建对应的连接(获取它们的信息)

    • 每个哨兵不断地用ping命令看主服务器有没有下线,如果主服务器在「配置时间」内没有正常响应,那当前哨兵就「主观」认为该主服务器下线了

    • 其他「哨兵」同样也会ping该主服务器,如果「足够多」(还是看配置)的哨兵认为该主服务器已经下线,那就认为「客观下线」,这时就要对主服务器执行故障转移操作。

    • 「哨兵」之间会选出一个「领头」,选出领头的规则也比较多,总的来说就是先到先得(哪个快,就选哪个)

    • 由「领头哨兵」对已下线的主服务器进行故障转移

      • 过程
        • 首先要在「从服务器」上挑选出一个,来作为主服务器
        • (这里也挑选讲究,比如:从库的配置优先级、要判断哪个从服务器的复制offset最大、RunID大小、跟master断开连接的时长…)
        • 然后,以前的从服务器都需要跟新的主服务器进行「主从复制」
        • 已经下线的主服务器,再次重连的时候,需要让他成为新的主服务器的从服务器

了解,我想问问,Redis在主从复制和故障转移的过程中会导致数据丢失吗

  • 会的

    • 1)从上面的「主从复制」流程来看,这个过程是异步的(在复制的过程中:主服务器会一直接收请求,然后把修改命令发给从服务器)

    • 假如主服务器的命令还没发完给从服务器,自己就挂掉了。这时候想要让从服务器顶上主服务器,但从服务器的数据是不全的

    • 2)还有另一种情况就是:有可能哨兵认为主服务器挂了,但真实是主服务器并没有挂(网络抖动),而哨兵已经选举了一台从服务器当做是主服务器了,此时「客户端」还没反应过来,还继续写向旧主服务器写数据

    • 等到旧主服务器重连的时候,已经被纳入到新主服务器的从服务器了…所以,那段时间里,客户端写进旧主服务器的数据就丢了

  • 上面这两种情况(主从复制延迟&&脑裂),都可以通过配置来「尽可能」避免数据的丢失

  • (达到一定的阈值,直接禁止主服务器接收写请求,企图减少数据丢失的风险)

要不再来聊聊Redis分片集群?

  • 分片集群就是往每个Redis服务器存储一部分数据,所有的Redis服务器数据加起来,才组成完整的数据(分布式)
  • 要想组成分片集群,那就需要对key进行「路由」(分片)
    • 现在一般的路由方案有两种:「客户端路由」(SDK)和「服务端路由」(Proxy)
    • 客户端路由的代表(Redis Cluster),服务端路由的代表(Codis)
    • 区别?

总结

Redis实现高可用:

  • AOF/RDB持久化机制
  • 主从架构(主服务器挂了,手动由从服务器顶上)
  • 引入哨兵机制自动故障转义

主从复制原理:

  • PSYNC命令两种模式:完全重同步、部分重同步
  • 完全重同步:主从服务器建立连接、主服务器生成RDB文件发给从服务器、主服务器不阻塞(相关修改命令记录至buffer)、将修改命令发给从服务器
  • 部分重同步:从服务器断线重连,发送RunId和offset给主服务器,主服务器判断offset和runId,将还未同步给从服务器的offset相关指令进行发送

哨兵机制:

  • 哨兵可以理解为特殊的Redis服务器,一般会组成哨兵集群
  • 哨兵主要工作是监控、告警、配置以及选主
  • 当主服务器发生故障时,会「选出」一台从服务器来顶上「客观下线」的服务器,由「领头哨兵」进行切换

数据丢失:

  • Redis的主从复制和故障转移阶段都有可能发生数据丢失问题(通过配置尽可能避免)
l