G1垃圾收集器(下) - 垃圾回收系列(五)

G1垃圾收集器(下)

前两个章节,我们对G1的回收流程和关键技术,进行了阐述。本文我们解析一下G1垃圾回收的日志,进而更深入的理解G1垃圾回收器。理解这些日志,也为我们在生产中,排查GC故障,提供一些思路。

一、测试代码

1.1 测试代码

java 复制代码
public class GCTest {

    //executor
    private static final ThreadPoolExecutor EXECUTOR = new ThreadPoolExecutor(4, 4,
            10, TimeUnit.MINUTES, new ArrayBlockingQueue<>(100),
            new HellohuThreadFactory("hello"), new ThreadPoolExecutor.DiscardPolicy());
    //全局队列,用于浪费内存
    private static final Queue<WasteMemory> GLOBAL_WASTE = new LinkedBlockingQueue<>();

    //无意义的浪费内存的任务
    private static class WasteMemory implements Runnable {

        private final int byteArrayLength;//浪费内存大小,单位byte
        private final int percentIntoGlobalWaste;//进入全局队列的概率
        private final int percentReleaseGlobalWaste;//释放全局队列的概率
        private byte[] waste;//用于浪费内存的数组

        private WasteMemory(int byteArrayLength, int percentIntoGlobalWaste, int percentReleaseGlobalWaste) {
            this.byteArrayLength = byteArrayLength;
            this.percentIntoGlobalWaste = percentIntoGlobalWaste;
            this.percentReleaseGlobalWaste = percentReleaseGlobalWaste;
        }

        @Override
        public void run() {

            //申请内存
            waste = new byte[byteArrayLength];
            //做些无意义的事情
            for (int i = 2; i < waste.length; i++) {
                waste[i] = (byte) (waste[i - 1] + waste[i - 2]);
            }
            //释放内存
            ThreadLocalRandom random = ThreadLocalRandom.current();
            int randomReleasePercent = random.nextInt(100);
            if (percentReleaseGlobalWaste > randomReleasePercent) {
                GLOBAL_WASTE.poll();
            }
            //浪费内存
            int randomWastePercent = random.nextInt(100);
            if (percentIntoGlobalWaste > randomWastePercent) {
                GLOBAL_WASTE.add(this);
            }
        }

    }

    public static void main(String[] args) throws InterruptedException {

        ThreadLocalRandom random = ThreadLocalRandom.current();
        while (true) {
            int waste = random.nextInt(1024, 640 * 1024);
            EXECUTOR.execute(new WasteMemory(waste, 20, 15));
            Thread.sleep(1);
        }
    }
}
  1. 简单的写了一个浪费内存的任务
    1. 构造一个byte数组,用于浪费内存;
    2. 做一些无意义的事情;
    3. 以一定的概率,释放1个全局内存;
    4. 以一定的概率,浪费1个全局内存;
  2. 主函数,死循环构造上述任务,加入线程池
    1. 生成一个1k-640k的随机数,作为浪费内存的大小;
    2. 20%的概率浪费内存,15%的概率,释放内存;
    3. 1ms执行一次。

从上面代码中可以看出,以概率学的角度来讲,最终一定会因为OOM挂掉。你可以通过调整上面的浪费内存大小、浪费回收的概率,得到不同的垃圾回收日志。

1.2 测试参数

ruby 复制代码
-XX:+UseG1GC -Xmx1g -Xms1g -XX:-PrintCommandLineFlags  -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCApplicationStoppedTime -XX:+PrintGCApplicationConcurrentTime `

参数含义

  • -XX:+UseG1GC 使用G1垃圾收集器
  • -Xmx1g -Xms1g 最大最小内存均为1g
  • -XX:-PrintCommandLineFlags 打印GC的一些参数
  • -XX:+PrintGCDetails 打印GC详情
  • -XX:+PrintGCDateStamps 打印GC的时间戳
  • -XX:+PrintGCApplicationStoppedTime 打印应用在GC时暂停的时间(确切说是安全点)
  • -XX:+PrintGCApplicationConcurrentTime 打印应用在GC之前的并发运行的时间(确切说是安全点)

这些日志默认打印到控制台上,生产上,最好配置打印到日志文件中:

  • -Xloggc:/path/to/gc.log 日志文件路径
  • -XX:+UseGCLogFileRotation 开启滚动日志
  • -XX:NumberOfGCLogFiles= 保留日志的最大数量
  • -XX:GCLogFileSize= 每个日志文件的大小

建议

建议在生产上,这些GC参数都设置上,假如GC出现问题,可以给我们提供一些日志排查的思路。

二、日志介绍

2.1 年轻代GC

以下为一段年轻代GC的日志,看起来很多,如果对G1的垃圾回收流程理解深入的话,就比较好理解。

less 复制代码
2024-07-13T11:23:45.090+0800: [GC pause (G1 Evacuation Pause) (young), 0.0104548 secs]
   [Parallel Time: 7.2 ms, GC Workers: 8]
      [GC Worker Start (ms): Min: 11735.8, Avg: 11736.0, Max: 11736.8, Diff: 1.0]
      [Ext Root Scanning (ms): Min: 0.0, Avg: 0.2, Max: 0.4, Diff: 0.4, Sum: 1.8]
      [Update RS (ms): Min: 0.0, Avg: 0.4, Max: 0.5, Diff: 0.5, Sum: 3.0]
         [Processed Buffers: Min: 0, Avg: 2.9, Max: 4, Diff: 4, Sum: 23]
      [Scan RS (ms): Min: 0.0, Avg: 0.1, Max: 0.1, Diff: 0.1, Sum: 0.9]
      [Code Root Scanning (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
      [Object Copy (ms): Min: 5.4, Avg: 5.5, Max: 5.6, Diff: 0.3, Sum: 43.9]
      [Termination (ms): Min: 0.0, Avg: 0.1, Max: 0.3, Diff: 0.2, Sum: 1.0]
         [Termination Attempts: Min: 1, Avg: 1.8, Max: 3, Diff: 2, Sum: 14]
      [GC Worker Other (ms): Min: 0.0, Avg: 0.1, Max: 0.1, Diff: 0.1, Sum: 0.5]
      [GC Worker Total (ms): Min: 5.6, Avg: 6.4, Max: 6.7, Diff: 1.1, Sum: 51.2]
      [GC Worker End (ms): Min: 11742.4, Avg: 11742.4, Max: 11742.5, Diff: 0.1]
   [Code Root Fixup: 0.0 ms]
   [Code Root Purge: 0.0 ms]
   [Clear CT: 0.5 ms]
   [Other: 2.7 ms]
      [Choose CSet: 0.0 ms]
      [Ref Proc: 0.6 ms]
      [Ref Enq: 0.0 ms]
      [Redirty Cards: 1.0 ms]
      [Humongous Register: 0.1 ms]
      [Humongous Reclaim: 0.5 ms]
      [Free CSet: 0.3 ms]
   [Eden: 464.0M(464.0M)->0.0B(439.0M) Survivors: 47.0M->64.0M Heap: 750.3M(1024.0M)->99.3M(1024.0M)]
 [Times: user=0.11 sys=0.00, real=0.01 secs] 

下面我们逐行解读。

1. 2024-07-13T11:23:45.090+0800: [GC pause (G1 Evacuation Pause) (young), 0.0104548 secs]

  • 2024-07-13T11:23:45.090+0800 : 发生GC时间;
  • GC pause (G1 Evacuation Pause) (young)
    • G1 Evacuation Pause G1垃圾回收暂停,如果申请了大对象,这里会提示Humongous Allocation;
    • (young) GC类型,年轻代回收;
  • 0.0104548 secs : 用时0.0104548 secs。

2. [Parallel Time: 7.2 ms, GC Workers: 8] 第二大块,为GC线程并行的部分

  • Parallel Time: 7.2 ms : 并行耗时7.2ms
  • GC Workers: 8 : GC并发线程数量,可以使用-XX:ParallelGCThreads=指定,默认值与cpu支持的线程数有关,支持线程数小于8,为cpu线程数,超过8个,则为线程数的5/8。

注意:这个数量并不是越大越好,假如cpu仅支持8线程并发,而你设置为16,不仅不会让垃圾回收变快,反而会因为线程的上下文切换变得更慢。

2.1 [GC Worker Start (ms): Min: 11735.8, Avg: 11736.0, Max: 11736.8, Diff: 1.0]

GC线程启动的最小/最大/平均时间,这个时间是相对(相对虚拟机启动)时间,单位ms,理想情况他们几乎同时启动,Diff趋近与0。

2.2 [Ext Root Scanning (ms): Min: 0.0, Avg: 0.2, Max: 0.4, Diff: 0.4, Sum: 1.8]

堆外Root(栈、全局变量等)引用当前回收集(collection set)扫描所用时间。

所谓回收集(collection set)就是垃圾回收分区集合。young gc包含所有年轻代,mixed gc包含所有年轻代和部分选中的老年代。

2.3 Update RS (ms)

yaml 复制代码
[Update RS (ms): Min: 0.0, Avg: 0.4, Max: 0.5, Diff: 0.5, Sum: 3.0]
    [Processed Buffers: Min: 0, Avg: 2.9, Max: 4, Diff: 4, Sum: 23]

之前的文章讲到过,G1的每个区域,都有一个记忆集,用于记录其他分区到本分区的引用。使用写后屏障维护,放入到日志缓冲区,异步处理。 这里日志,显示的是,在GC暂停前,还没有处理完成的日志缓存区花费的时间和处理缓冲区的数量。

  • Update RS (ms) : 更新记忆集花费的时间。
  • Processed Buffers : 处理的日志数量。

2.4 [Scan RS (ms): Min: 0.0, Avg: 0.1, Max: 0.1, Diff: 0.1, Sum: 0.9]

扫描记忆集花费的时间。也就是扫描非回收集(Collection Set)到回收集的引用的时间

2.5 [Code Root Scanning (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]

扫描编译源代码到回收集花费的时间,这个显示的位置有误,应该是Ext Root Scanning的一部分。

参考: AOT code root scanning shows in the wrong position in the logs

2.6 [Object Copy (ms): Min: 5.4, Avg: 5.5, Max: 5.6, Diff: 0.3, Sum: 43.9]

存活对象,复制到其他区域花费的时间。

2.7 Termination (ms)

yaml 复制代码
[Termination (ms): Min: 0.0, Avg: 0.1, Max: 0.3, Diff: 0.2, Sum: 1.0]
    [Termination Attempts: Min: 1, Avg: 1.8, Max: 3, Diff: 2, Sum: 14]
  • Termination (ms) : 线程停止花费的时间。当一个线程完成本线程的任务后,就会进入一个临界区,并尝试帮助其他垃圾线程完成任务(steal outstanding tasks)。这个时间代表它尝试停止,与真正停所花费的时间。
  • Termination Attempts : 一个线程尝试暂停的次数,每成功偷取一次任务,该次数就+1。

2.8 [GC Worker Other (ms): Min: 0.0, Avg: 0.1, Max: 0.1, Diff: 0.1, Sum: 0.5]

GC线程花费在其他(也就是上述流程之外的)地方所花费的时间。

2.9 [GC Worker Total (ms): Min: 5.6, Avg: 6.4, Max: 6.7, Diff: 1.1, Sum: 51.2]

GC所有线程花费的总耗时。

2.10 [GC Worker End (ms): Min: 11742.4, Avg: 11742.4, Max: 11742.5, Diff: 0.1]

GC线程结束的相对时间,理想情况下,几乎同时停止,也就是Diff趋近于0。

3 串行任务

以上编号2开头的,为并行任务,之后的第三大块,就都是串行的任务了。 3.1 [Code Root Fixup: 0.0 ms]

修复代码中指定堆的指针,因为这些指针,可能已经移动了。

3.2 [Code Root Purge: 0.0 ms]

清理code Root相关的数据结构。

3.3 [Clear CT: 0.5 ms]

清理卡表(card table),已经清理的区域的卡表,需要重置,这里是重置这些卡表需要的时间。

3.4 [Other: 2.7 ms]

其他没有统计到上面的任务耗时。

  • [Choose CSet: 0.0 ms] : 从内存分区中,选择分区到收集区(CSet)所花费的时间;
  • [Ref Proc: 0.6 ms] : 处理各种引用(soft/weak/final/phantom/JNI)花费的时间;
  • [Ref Enq: 0.0 ms] :引用处理时,有些需要进入队列,这里记录进入队列花费的时间;
  • [Redirty Cards: 1.0 ms] :存活对象复制到其他分区,重置这种分区的卡表(记忆集);
  • [Humongous Register: 0.1 ms] :前文提到过,在JDK 8u60之后,部分大对象可以在年轻代期间回收。这个时间为评估大对象是否能回收花费的时间;
  • [Humongous Reclaim: 0.5 ms] : 回收大对象花费的时间;
  • [Free CSet: 0.3 ms] :释放被完全清理的分区,到free 列表所花费的时间。

4. [Eden: 464.0M(464.0M)->0.0B(439.0M) Survivors: 47.0M->64.0M Heap: 750.3M(1024.0M)->99.3M(1024.0M)]

垃圾回收后,对内存状态:

  • Eden: 464.0M(464.0M)->0.0B(439.0M) : Eden分区,从(464.0M)缩容到(439.0M),对象或者回收,或者复制到其他分区;
  • Survivors: 47.0M->64.0M : Survivors分区,由47.0M 增长到 64.0M;
  • Heap: 750.3M(1024.0M)->99.3M(1024.0M) : 堆存活对象由750.3M降到99.3M,堆大小没有改变。

5. [Times: user=0.11 sys=0.00, real=0.01 secs]

总耗时

  • user=0.11 :用户线程花费的时间,注意这里是多线程的累计值,通常要比real时间大很多;
  • sys=0.00 :内核态花费时间,也是累计时间;
  • real=0.01 : 真正的暂停时间;

2.2 并发标记

当老年代占用的比例大于阈值后,会触发并发标记流程,并发标记流程不会立即开始,而是与下一次的young gc一起进行,在日志中,我们可以看到一条,带有(initial-mark)的young gc日志:

less 复制代码
2024-07-13T11:34:30.897+0800: [GC pause (G1 Humongous Allocation) (young) (initial-mark), 0.0021755 secs]
   [Parallel Time: 1.1 ms, GC Workers: 8]
      [GC Worker Start (ms): Min: 46258.7, Avg: 46258.8, Max: 46258.9, Diff: 0.2]
      [Ext Root Scanning (ms): Min: 0.0, Avg: 0.3, Max: 0.8, Diff: 0.8, Sum: 2.1]
      [Update RS (ms): Min: 0.0, Avg: 0.2, Max: 0.8, Diff: 0.8, Sum: 1.8]
         [Processed Buffers: Min: 0, Avg: 1.4, Max: 5, Diff: 5, Sum: 11]
      [Scan RS (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
      [Code Root Scanning (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
      [Object Copy (ms): Min: 0.0, Avg: 0.3, Max: 0.5, Diff: 0.4, Sum: 2.2]
      [Termination (ms): Min: 0.0, Avg: 0.2, Max: 0.3, Diff: 0.3, Sum: 1.7]
         [Termination Attempts: Min: 1, Avg: 1.0, Max: 1, Diff: 0, Sum: 8]
      [GC Worker Other (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
      [GC Worker Total (ms): Min: 0.8, Avg: 1.0, Max: 1.1, Diff: 0.2, Sum: 7.8]
      [GC Worker End (ms): Min: 46259.8, Avg: 46259.8, Max: 46259.8, Diff: 0.0]
   [Code Root Fixup: 0.0 ms]
   [Code Root Purge: 0.0 ms]
   [Clear CT: 0.3 ms]
   [Other: 0.7 ms]
      [Choose CSet: 0.0 ms]
      [Ref Proc: 0.2 ms]
      [Ref Enq: 0.0 ms]
      [Redirty Cards: 0.2 ms]
      [Humongous Register: 0.1 ms]
      [Humongous Reclaim: 0.1 ms]
      [Free CSet: 0.1 ms]
   [Eden: 1024.0K(225.0M)->0.0B(237.0M) Survivors: 5120.0K->5120.0K Heap: 513.1M(1024.0M)->512.1M(1024.0M)]
 [Times: user=0.00 sys=0.00, real=0.00 secs] 

整体上与普通的young gc并没有什么区别,其实mixed gc的日志也没什么区别,因为这里垃圾回收,可以抽象为对回收集(Collection CSet)进行回收,大致流程,没有多大区别。

我们带着日志,回顾下并发标记流程。

1. 初始标记(Initial Mark)

借助年轻代回收停顿,扫描所GCRoots能直接关联到的对象。同时对堆打一个逻辑快照,其实就是每个region设置了两个指针,称之为SATB(Snapshot-At-The-Beginning ),在快照外的,都被认为是存活对象。

2. 根分区扫描(Root Region Scanning)

扫描其他分区(Root Region)到需要扫描分区的引用,也就是年轻代到老年代引用,刚刚经历过一次young gc,并且SATB外的对象也不扫描,那么,这个扫描扫的,也就是Survivor分区到老年代的引用。

ini 复制代码
2024-07-13T11:34:30.899+0800: [GC concurrent-root-region-scan-start]
2024-07-13T11:34:30.899+0800: [GC concurrent-root-region-scan-end, 0.0001801 secs]

3. 并发标记(Concurrent Marking)

  1. 从上述两个阶段找到的直接关联到老年代的引用,继续扫描,使用bitmap标记所有存活对象,所有SATB外的都被认为是存活对象,因此只需要扫描SATB内的即可。
  2. 使用写前屏障,记录堆中引用的修改,到SATB BUFFER中。BUFFER满了之后,会被加入全局链表中,标记线程会周期处理这些被修改的引用。
  3. 与用户线程并发执行的线程数,默认值是上一小节中垃圾回收Worker数量的四分之一,也可以用参数-XX:ConcGCThreads设置。
ini 复制代码
2024-07-13T11:34:30.899+0800: [GC concurrent-mark-start]
2024-07-13T11:34:30.901+0800: [GC concurrent-mark-end, 0.0017811 secs]

4. 重新标记(Remark)

  1. 处理上一阶段的SATB BUFFER以及全局链表中的引用修改。
  2. 处理引用(弱引用、软引用、虚引用、最终引用)
  3. 类卸载
yaml 复制代码
2024-07-13T11:34:30.901+0800: [GC remark 
    2024-07-13T11:34:30.901+0800: [Finalize Marking, 0.0006084 secs]
    2024-07-13T11:34:30.902+0800: [GC ref-proc, 0.0002658 secs] 
    2024-07-13T11:34:30.902+0800: [Unloading, 0.0007649 secs], 0.0026664 secs]
    [Times: user=0.03 sys=0.00, real=0.00 secs] 

5. 清除(Cleanup) 停止用户线程(STW),为mixed gc做准备工作

  1. 计算老年代各个Region的回收价值和成本进行排序。
  2. 对辅助垃圾回收的一些数据结构进行状态维护。
  3. 回收完全没有存活对象的老年代或者大对象,日志中可以看到堆大小变化,也就可以计算出这个阶段回收垃圾的大小。
ini 复制代码
2024-07-13T11:34:30.904+0800: [GC cleanup 515M->478M(1024M), 0.0012239 secs]
 [Times: user=0.00 sys=0.00, real=0.00 secs] 

6. 并发清除(Concurrent-cleanup)

与用户线程并发执行,完成上一步剩余的清理工作;清理上一步回收的分区相关的记忆集等数据结构,归还到内存空闲链表。

ini 复制代码
2024-07-13T11:34:30.906+0800: [GC concurrent-cleanup-start]
2024-07-13T11:34:30.906+0800: [GC concurrent-cleanup-end, 0.0000509 secs]

2.3 混合回收

在并发标记阶段完成后,通常会开启混合回收,你会看到先进行了一次年轻代回收,然后才是混合回收。

less 复制代码
2024-07-13T11:34:34.124+0800: [GC pause (G1 Evacuation Pause) (young), 0.0075060 secs]
   ...
   [Eden: 181.0M(181.0M)->0.0B(27.0M) Survivors: 8192.0K->24.0M Heap: 800.6M(1024.0M)->593.0M(1024.0M)]
 [Times: user=0.00 sys=0.00, real=0.01 secs] 

原因,我们在其他文章中已经介绍过:

G1会试图在一次不大于用户期望的暂停时间内,回收尽可能多的老年代,因此在混合回收阶段,年轻代的大小被设置为允许的最小值,由-XX:G1NewSizePercent确定,默认为5%。也正是因为需要先调整年轻代大小,在标记流程结束后,G1需要先进行一次年轻代的垃圾回收,回收后改变年轻代大小,才能开启混合回收。

在上面日志上可以看到,年轻代有181M缩小到27M。

之后可以看到mixed gc,日志与年轻代回收,没有大的区别。

less 复制代码
2024-07-13T11:34:32.770+0800: [GC pause (G1 Evacuation Pause) (mixed), 0.0068461 secs]
   [Parallel Time: 4.9 ms, GC Workers: 8]
      [GC Worker Start (ms): Min: 48132.2, Avg: 48132.2, Max: 48132.2, Diff: 0.1]
      [Ext Root Scanning (ms): Min: 0.1, Avg: 0.1, Max: 0.2, Diff: 0.1, Sum: 1.0]
      [Update RS (ms): Min: 0.7, Avg: 0.7, Max: 0.7, Diff: 0.1, Sum: 5.6]
         [Processed Buffers: Min: 6, Avg: 11.9, Max: 15, Diff: 9, Sum: 95]
      [Scan RS (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.1]
      [Code Root Scanning (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
      [Object Copy (ms): Min: 3.7, Avg: 3.8, Max: 3.8, Diff: 0.1, Sum: 30.1]
      [Termination (ms): Min: 0.0, Avg: 0.1, Max: 0.1, Diff: 0.1, Sum: 0.6]
         [Termination Attempts: Min: 1, Avg: 1.4, Max: 2, Diff: 1, Sum: 11]
      [GC Worker Other (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.1]
      [GC Worker Total (ms): Min: 4.7, Avg: 4.7, Max: 4.7, Diff: 0.1, Sum: 37.5]
      [GC Worker End (ms): Min: 48136.9, Avg: 48136.9, Max: 48136.9, Diff: 0.0]
   [Code Root Fixup: 0.0 ms]
   [Code Root Purge: 0.0 ms]
   [Clear CT: 0.6 ms]
   [Other: 1.3 ms]
      [Choose CSet: 0.0 ms]
      [Ref Proc: 0.5 ms]
      [Ref Enq: 0.0 ms]
      [Redirty Cards: 0.5 ms]
      [Humongous Register: 0.1 ms]
      [Humongous Reclaim: 0.1 ms]
      [Free CSet: 0.1 ms]
   [Eden: 23.0M(23.0M)->0.0B(188.0M) Survivors: 28.0M->7168.0K Heap: 616.0M(1024.0M)->576.6M(1024.0M)]
 [Times: user=0.00 sys=0.00, real=0.01 secs] 

2.4 Full GC

在G1中,Full GC是需要极力避免的,日志里,主要关注引起Full GC的原因,这里是(Allocation Failure),无法分配新对象。因为G1垃圾回收与用户线程是并行的,如果垃圾回收的速度没有用户快,就会退回到Full GC。

css 复制代码
2024-07-13T11:35:02.210+0800: [Full GC (Allocation Failure)  745M->697M(1024M), 0.0390765 secs]
   [Eden: 0.0B(51.0M)->0.0B(51.0M) Survivors: 0.0B->0.0B Heap: 745.5M(1024.0M)->697.8M(1024.0M)], [Metaspace: 4002K->4002K(1056768K)]
 [Times: user=0.05 sys=0.00, real=0.04 secs] 

参考:

相关推荐
Theodore_10221 小时前
4 设计模式原则之接口隔离原则
java·开发语言·设计模式·java-ee·接口隔离原则·javaee
冰帝海岸2 小时前
01-spring security认证笔记
java·笔记·spring
世间万物皆对象3 小时前
Spring Boot核心概念:日志管理
java·spring boot·单元测试
没书读了3 小时前
ssm框架-spring-spring声明式事务
java·数据库·spring
小二·3 小时前
java基础面试题笔记(基础篇)
java·笔记·python
开心工作室_kaic4 小时前
ssm161基于web的资源共享平台的共享与开发+jsp(论文+源码)_kaic
java·开发语言·前端
懒洋洋大魔王4 小时前
RocketMQ的使⽤
java·rocketmq·java-rocketmq
武子康4 小时前
Java-06 深入浅出 MyBatis - 一对一模型 SqlMapConfig 与 Mapper 详细讲解测试
java·开发语言·数据仓库·sql·mybatis·springboot·springcloud
转世成为计算机大神4 小时前
易考八股文之Java中的设计模式?
java·开发语言·设计模式
qq_327342735 小时前
Java实现离线身份证号码OCR识别
java·开发语言