关于懒加载和栈堆

懒加载在操作系统中的体现可以动态链接和虚拟内存

对于动态链接 我们会在程序的对应部分记录一个空

假如运行到这里了 就会根据PLT和GOT寻找遍历方法

找到对应的方法栈堆区并加载过来

对于虚拟内存 我们则会以进程为单位 分配缓存页

在不同的线程中 他们可能会出现同样的一个地址空间

这个地址空间是虚拟的 在调用到的时候 操作系统会通过CR3(一个存放的寄存器)

查找到其对应的物理地址 并且将这一页加载进来

这本质上就是懒加载的一个鲜活体现

在JVM中 也存在对应的体现 有java虚拟机栈 本地方法栈

两者本质上是存储不同方法的运行时实际数据

而这个栈帧的加载就是懒加载 当运行到这个方法的时候 才会将对应对容加载进来

执行方法时静态变量与线程安全相关

对于多线程环境下,我们首先看到的是,假如多个线程同一时间执行某一个方法,改变了某一个静态变量,则会出现竞态问题。所以我们发明出volatile关键字,保证程序的有序性,原子性,可见性。

这个底层可以理解为。在执行方法的时候,我们会从原空间获取到类的信息,并根据这个信息为不同的线程创建不同的栈帧。

而不同的线程操作静态变量的时候,他们不会去为静态变量创建副本(这里的他们指方法中的栈帧)。他们是直接操作堆中的共享对象。

但是

JVM中做了优化,会在CPU的高速缓存中放置静态变量的缓存。

而根据存储的不同层级推理一下,我们就可以知道,在方法获取变量执行的时候首先肯定是先查询CPU高速缓存,如果有的话就返回。没有的话再去主存中进行寻找。

而这个CPU的高速缓存就导致了老生常谈的竞态问题

volatile变量的本质,就是禁止CPU缓存优化。将每一次的更新操作都立同步到主内存,而读取的时候,也直接从主内存(堆)中读取。保证了读写操作与主内存同步。

堆和栈

栈和堆中存什么?

从生命周期来看,一个栈本质上就是一个方法的逻辑体现,在方法结束之后,栈就会消失,里面的数据也会被销毁;

堆本质上是共享内存(临界资源),它存储了所有栈没存上的运行时数据。而堆中的数据只会通过GC线程将其回收。

方法中涉及的所有基本数据类型变量都会存储到栈中,为什么?其所占空间小。

相反的,引用类型(包括数组)所占用的空间大。所以会存在堆中,存储空间大,然后等待GC回收。

方法区中执行方法的执行过程

方法区本质上是对外内存也就是直接内存。这个内存是不归JVM管的,是操作系统的部分内存。

首先通过方法区找到对应的方法地址信息。根据这个信息,创建对应的栈帧,并且根据信息内的指令不断的执行,最后返回执行完毕的状态。

创建对象的过程

  1. 类加载检查,检查这个类此前是否被加载过。通过符号引用实现,符号引用本质上与类信息形成一个映射关系。通过判断有没有符号引用就知道有没有相关的类信息。

  2. 根据获得的类信息,为新对象在堆中分配对应的空间。

  3. 为新对象中的各个属性赋零值

  4. 在方法区中记录对象的元信息,(对象头等)

  5. 执行构造函数,init 方法

  6. 初始化完成

双亲委派模型

有哪些类加载器?

启动类加载器 负责加载类的核心库 核心代码

扩展类加载器 负责夹杂扩展目录,包括依赖等

应用程序类加载器&系统类加载器 两者的本质上是一样的,负责加载用户设置的类

自定义类加载器 开发者自己定义的类加载器

类加载器加载类的过程

当类的加载请求到来时,会先给到子类类加载器,但是子类加载器本身先不直接加载它。而是把加载的请求给到父类加载器,依次传到最顶层的加载器。所以无论是什么类需要加载

他都需要到达最顶层的加载器当中

即Bootstrap Class Loader

然后父类加载器判断自己怎么能不能加载这个类,如果不能记载,就还给子类加载器开搞。

所有类都是这么干的

加载器怎么判断自身能不能加载他?

通过加载器的缓存和类路径(Classpath)。

并且可以加载器可以通过类名推断处其全路径,从而进行加载。

为什么需要它?

保证了加载时类的安全性以及记载的类的唯一性。

按照这一设计,所有的类都是先通过父类加载器先判断扫描的。就保证了它的安全性。因为加载器仅加载它信任的目录内的包。

假如有人恶意定义一个System包,那他也不在新人的目录内,所以无法加载的到java库中。

并且按照这个设计,就解耦了不同层次的包的加载。启动类只加载核心的类,扩展仅加载依赖相关的类。自己定义的Classpath会在应用程序加载器或自定义加载器中加载出来。

为什么说SPI机制会打破双亲委派机制

SPI机制在JAVA中的体现是,实现标准库所提供的一个接口,并且通过在配置文件中配置。让JAVA加载这个类。

Java中SPI机制的体现最鲜明的有JDBC。但是问题来了,如果使用SPI机制的话。三方实现按理来说应该是通过扩展类加载器或者是应用程序加载器。但是SPI不会走这条路,对应的加载器就直接对他进行加载。而不会执行双亲委派机制原有的环节。

加载类的真实过程

加载:首先从字节码文件中加载类,在方法区中分配空间。

验证:分配空间后会对类的相关信息(元信息)进行验证,验证其是否准确。

准备:假如准确的话,就会给其中的静态变量在堆中开辟空间,赋零值。

解析:将常量池中的符号引用(即虚拟地址)转换映射为真实的直接引用(真实地址)

初始化:执行编译器自动生成的构造器方法。这个构造器方法中的逻辑本质上就是给静态变量赋值,并且执行静态代码块。

使用:正确使用创建对象

卸载:其对应的ClassLoader已经被回收;不存在她所对应的对象实例;代码中没有指向它的反射引用

垃圾回收

如何判断是垃圾?

引用计数法和可达性分析算法

引用计数法

给每个对象分配一个计数器,每有一个地方引用它,计数器+1。引用失效的时候,计数器-1。但是在产生循环引用的时候,他就无法解决了。

可达性分析算法

使用该算法,本质上就是根据引用关系的图进行遍历。假如可以遍历到对应的对象,就代表着它有被引用,假如遍历不到对应的对象。就代表着它没有被引用,此时认为这个对象可以被回收。

为什么需要垃圾回收算法

自动检测和回收不再需要使用的对象,从而释放它们所占用的内存空间。避免产生内存泄漏,防止了内存溢出。

内存泄漏是指部分对象一直用不上却还在占用资源。

内存溢出是指超过JVM分配的空间(-xmX)

垃圾回收算法有?

这部分可以类比数据库和操作系统中都会出现的内存碎片化问题进行理解

首先我们通过可达性标记算法或者引用计数法可得出哪个是垃圾。

标记-清除

通过上方的方法对对象进行了标记,申请之后对对象进行回收。但是这种方法会造成大量的碎片化内存。

这是由于JVM分配堆空间的方式决定的。它是通过指针碰撞分配不同的堆空间。指针碰撞本质山就是维护目前存储的最大地址。然后当新的分配请求到来的时候,就进行顺序写。这种方案下写的效率极高。

但是在程序运行中,它本身是不知道有哪些对象或引用是会先被注销的。所以会出现内存的碎片化。

which就会导致申请大量内存的对象的时候还是没空间,从而再次触发GC. 甚至导致OOM.

复制算法

复制算法就是另外一个极端一点的,将内存分成两块。申请内存的时候,选择其中一块进行存储。当内存不够用的时候,通过引用计数法或者可达性标记法判断哪些内存可用。将可用的移到另外一半,并且清理不可用的。依次反复。

这样解决了内存碎片化,但是耗费了一半的内存空间。

同时根据之前所说的垃圾回收算法可以得知。到达老年代的对象一般都是生命周期比较长的对象。假如程序中老年代的程序很多,这时候每一次复制的负担就很重。

标记-整理

根据上方的分析,我们可以结合两个方法,在通过引用计数法和可达性标记法标记出之后。我们将存活的对象移到内存的一侧。并且将无引用的直接清理掉。

分代回收

非常像MySQLBufferpool中的LRU的改进版。当所指内容在经过了多次GC之后还存在引用,还没有被回收,我们就可以将其视为老对象,放在old的区域。

GCROOT中包括什么

栈中正在引用的对象

方法栈中正在引用的对象

静态属性引用的对象

方法区常量引用的对象

什么情况下会stoptheworld

初始标记阶段(STW,标记直接可达对象)

  • GC Roots 直接关联的对象被标记,非常快速,但会引发 短暂 STW
1
2
3
4
mathematica复制代码GC Roots

┌─┴─┐ (STW 标记直接可达对象)
O1 O2 ← 标记完毕,后续并发标记将从这些对象继续

并发标记阶段(GC 线程与应用线程并发执行)

  • 从初始标记的结果出发,递归地标记所有可达对象。
  • 此阶段 不引发 STW,应用线程和 GC 线程并发执行。
1
2
3
4
5
6
7
8
mathematica复制代码GC Roots

┌─┴─┐ (并发标记阶段开始)
O1 O2
│ │ GC 线程并发地遍历引用链
O3 O4

O5 ← 递归标记所有可达对象

初次之外,在并发之后程序可能发生了改变,所以需要在这个基础上在STW依次,标记并发过程中发生改变的对象。

初次之外,这只是对清理的或不需要清理的做了标记

这个是标记的,只有当GC Root标记第一次。初次之外,在清理阶段和复制阶段都需要进行STW。如果这两个阶段中设计的对象较大,或者较多,都会占用较长的时间,也就会造成了内存的抖动。

什么时候会触发MajorGC和FullGC

MajorGC发生在老年代 通常由MinorGC无法满足内存需求时触发

FullGC发生在整个堆且是STW的

GC的整个环节

  1. 触发GC:堆空间快慢,代码中建议GC
  2. 分代机制:分为普通对象和大对象,前者Eden,后者直接进入老年代(大对象一般来说生命周期长包含数据多)
  3. GC的类型:MinorGC, MajorGC, FullGC。

MinorGC将数据从新生代复制到Survivor区。MajorGC则回收老年代。FullGC直接进行STW对整个堆进行回收

  1. 标记过程(标记垃圾):有引用计数法和可达性标记法。引用计数法是给每一个对象一个引用,判断它的引用次数。可达性标记法则是通过设置的GCRoots判断出他的儿子。

这整个判断的过程中,初始化的直接标记是STW的,之后会进行并发标记。在并发标记结束后。会进行重新的标记,防止在并发的过程中造成了引用变化,而这个过程也是STW的。

标记了之后,我们就需要对对象进行复制或者转移。

这是整个GC的操作环节,其中还有很多细节。譬如说GCRoots是怎么设置的,会不会更改。在新建对象时,进入GC循环时的指针偏移等。

*

但是存在一个问题,无论是什么类型的GC,他们的回收算法都是那几种

也就是什么

复制算法 标记-清除 标记-整理

但是假如此时有很多的对象都不满足GC的条件,都存在引用,此时就会对程序造成了影响。

这种情况下,GC会认识到堆内存不足,一直开启GC。但是其中的内容也一直不满足GC的条件。两者一并就造成了资源的二次浪费。

如何优化这种情况?

5.1 分析内存使用情况

  • 使用 JVM 工具(如 jstatjmapVisualVMMAT)来分析堆内存占用情况,检查哪些对象占用了大量空间且无法被回收。

5.2 调整 GC 参数

  • 增大堆空间:通过

    -Xms 和 -Xmx

    参数设置合适的堆内存大小,减少 GC 频率。

    1
    java -Xms512m -Xmx1024m -XX:+PrintGCDetails MyApp
  • 优化新生代大小:增加新生代的空间,减少 Minor GC 频率。

    -Xmn256m

  • 调整垃圾回收器:对于需要低停顿的应用,选择 G1 GCZGCShenandoah GC,减少 GC 对性能的影响。

5.3 减少内存占用

  • 优化代码,减少不必要的对象创建。
  • 检查 长生命周期对象 是否占用老年代空间过多。
  • 使用对象池、缓存机制等减少对象分配次数。

5.4 检查代码中的强引用问题

  • 确保对象不被长时间引用,避免内存泄漏。
  • 考虑使用 弱引用(WeakReference)软引用(SoftReference) 处理缓存对象。