Java(三)

本文最后更新于 2024年7月9日 下午

Java(三)

59. HashMap底层源码

  • HashMap 的底层结构在 jdk1.7 中由数组+链表实现,在 jdk1.8 中由 数组+链表+红黑树 实现
  • JDK 1.8 的 HashMap 当链表节点较少时仍然是以链表存在,当链表节点较多时(大于8)并且当前数组的长度大于 64 时会转为红黑树。
  • JDK1.8 之前 HashMap 由 数组+链表 组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突(两个对象调用的hashCode方法计算的哈希码值一致导致计算的数组索引值相同)而存在的(“拉链法”解决冲突)。JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(或者红黑树的边界值,默认为 8)并且当前数组的长度大于64时,此时此索引位置上的所有数据改为使用红黑树存储。

60. Java内存机制

  • Java 的内存机制是指 Java 程序在运行时,如何管理和分配内存资源,Java采用自动内存管理机制,即通过垃圾回收器来自动管理内存的分配和释放。

  • Java的内存可以分为以下几个区域:

    1. 方法区:用于存储类的结构信息,如类的成员变量、方法代码等。

    2. 堆:用于存储对象实例。所有通过 new 关键字创建的对象都在堆中分配内存。堆是 Java 中最大的一块内存区域,它被所有线程共享。

    3. 栈:每个线程都有一个独立的栈,用于存储局部变量、方法参数、调用栈等。栈中的数据是按照先进后出的方式进行管理。

    4. 本地方法栈:用于存储 Java 以外的本地方法的调用和执行。

      1. 程序计数器:用于记录当前线程执行的位置,也就是下一条要执行的指令。
    5. 运行时常量池:用于存储编译期生成的各种字面量和符号引用。

  • 垃圾回收器会自动监测并回收不再使用的内存对象,释放内存资源,当一个对象没有被任何引用所指向时,就会被判定为垃圾对象,垃圾回收器会将其回收并释放内存。

  • Java的内存机制使得程序员无需手动管理内存资源,大大简化了开发过程,提高了代码的可靠性和安全性,同时,合理地使用内存和优化内存使用也有助于提升程序的性能。

【JavaSE专栏18】用大白话讲解 Java 中的内存机制_java内存-CSDN博客

61. JVM内存分哪几个区,每个区的作用是什么

JVM 内存分区图

  • java虚拟机主要分为以下几个区

    1. 方法区

      • 有时候也成为永久代,在该区内很少发生垃圾回收,但是并不代表不发生GC,在这里进行的GC主要是对方法区里的常量池和对类型的卸载
      • 方法区主要用来存储已被虚拟机加载的类的信息、常量、静态变量和即时编译器编译后的代码等数据。
      • 该区域是被线程共享的。
      • 方法区里有一个运行时常量池,用于存放静态编译产生的字面量和符号引用。该常量池具有动态性,也就是说常量并不一定是编译时确定,运行时生成的常量也会存在这个常量池中。
    2. 虚拟机栈

      • 虚拟机栈也就是我们平常所称的栈内存,它为java方法服务,每个方法在执行的时候都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接和方法出口等信息。
      • 虚拟机栈是线程私有的,它的生命周期与线程相同。
      • 局部变量表里存储的是基本数据类型、returnAddress类型(指向一条字节码指令的地址)和对象引用,这个对象引用有可能是指向对象起始地址的一个指针,也有可能是代表对象的句柄或者与对象相关联的位置。局部变量所需的内存空间在编译器间确定
      • 操作数栈的作用主要用来存储运算结果以及运算的操作数,它不同于局部变量表通过索引来访问,而是压栈和出栈的方式
      • 每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接.动态链接就是将常量池中的符号引用在运行期转化为直接引用。
    3. 本地方法栈

      • 本地方法栈和虚拟机栈类似,只不过本地方法栈为Native方法服务。
      • java堆是所有线程所共享的一块内存,在虚拟机启动时创建,几乎所有的对象实例都在这里创建,因此该区域经常发生垃圾回收操作。
    4. 程序计数器:

      • 内存空间小,字节码解释器工作时通过改变这个计数值可以选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理和线程恢复等功能都需要依赖这个计数器完成。该内存区域是唯一一个java虚拟机规范没有规定任何OOM情况的区域。

62. JVM内存结构与新生代划分

  • Java 虚拟机(JVM)内存结构包含堆和栈,而堆又被划分为新生代、老年代和持久代。新生代进一步分为 Eden 区和两个 Survivor 区(S0 和 S1)。这种内存划分有助于提高垃圾回收的效率和降低内存碎片化。

  • JVM内存结构

    • 共享内存区划分: 共享内存区包括持久代和堆。持久代包括方法区和其他区域。
    • Java堆: Java 堆由新生代和老年代组成。
    • 新生代: 主要用于存储新创建的对象,包括 Eden 区、S0 和 S1。
  • 参数配置

    • -Xms 和 -Xmx 参数分别用于设置 Java 堆内存的初始大小最大大小

    • -Xmn 参数用于表示年轻代大小,包括 Eden + Survivor1 + Survivor2

    • -Xss 参数表示每个线程的堆栈大小,即创建线程后,分配给每一个线程的内存大小

    • -XX:NewRatio 参数用于配置新生代和老年代的大小比例,默认为 2,即 年轻代:年老代=1:2。可以通过调整这个参数来适应不同应用的需求。

    • -XX:SurvivorRatio 参数用于配置 Eden 区和 Survivor 区的大小比例,默认为 8:1:1,即两个 Survivor 区相对于一个 Eden 区的比例。这个参数也可以根据应用的特性进行调整。

    • -XX:+MaxTenuringThreshold 参数用于配置对象在 Survivor 区中的最大复制次数,默认为 15。当对象经历了一定次数的 Minor GC 后,会被晋升到老年代。可以根据对象的生命周期和程序的特性进行调整。

    • -XX:MaxPermSize=n: 设置持久代大小

    • 收集器设置
      -XX:+UseSerialGC: 设置串行收集器
      -XX:+UseParallelGC: 设置并行收集器
      -XX:+UseParalledlOldGC: 设置并行年老代收集器
      -XX:+UseConcMarkSweepGC: 设置并发收集器

    • 垃圾回收统计信息
      -XX:+PrintGC
      -XX:+PrintGCDetails
      -XX:+PrintGCTimeStamps
      -Xloggc:filename

    • 并行收集器设置
      -XX:ParallelGCThreads=n: 设置并行收集器收集时使用的CPU数。并行收集线程数。
      -XX:MaxGCPauseMillis=n: 设置并行收集最大暂停时间
      -XX:GCTimeRatio=n: 设置垃圾回收时间占程序运行时间的百分比。公式为1/(1+n)

    • 并发收集器设置
      -XX:+CMSIncrementalMode: 设置为增量模式。适用于单CPU情况。
      -XX:ParallelGCThreads=n: 设置并发收集器年轻代收集方式为并行收集时,使用的CPU数。并行收集线程数。

    • -XX:PretenureSizeThreshold: 可设置大于该值的对象直接在老年代分配,避免其在Eden和Survivor来回复制占用内存,其只能对Serial和ParNew有效

63. Java中垃圾收集的常用算法有哪些?

  1. 复制算法:年轻代中使用的是Minor GC,这种GC算法采用的是复制算法(Copying)

    • 该算法将内存平均分成两部分,然后每次只使用其中的一部分,当这部分内存满的时候,将内存中所有存活的对象复制到另一个内存中,然后将之前的内存清空,只使用这部分内存,循环下去。
    • 优点:实现简单;不产生内存碎片
    • 缺点:每次运行,总有一半内存是空的,导致可使用的内存空间只有原来的一半。
  2. 标记-清除算法:老年代一般是由标记清除或者是标记清除与标记整理的混合实现

    • 为每个对象存储一个标记位,记录对象的状态(活着或是死亡)。分为两个阶段,一个是标记阶段,这个阶段内,为每个对象更新标记位,检查对象是否死亡;第二个阶段是清除阶段,该阶段对死亡的对象进行清除,执行 GC 操作。
    • 优点:最大的优点是,标记—清除算法中每个活着的对象的引用只需要找到一个即可,找到一个就可以判断它为活的。此外,更重要的是,这个算法并不移动对象的位置。
    • 缺点:它的缺点就是效率比较低(递归与全堆对象遍历)。每个活着的对象都要在标记阶段遍历一遍;所有对象都要在清除阶段扫描一遍,因此算法复杂度较高。没有移动对象,导致可能出现很多碎片空间无法利用的情况。
  3. 标记-整理算法:老年代一般是由标记清除或者是标记清除与标记整理的混合实现

    • 标记-压缩法是标记-清除法的一个改进版。同样,在标记阶段,该算法也将所有对象标记为存活和死亡两种状态;不同的是,在第二个阶段,该算法并没有直接对死亡的对象进行清理,而是将所有存活的对象整理一下,放到另一处空间,然后把剩下的所有对象全部清除。这样就达到了标记-整理的目的。
      优点:该算法不会像标记-清除算法那样产生大量的碎片空间。
    • 缺点:如果存活的对象过多,整理阶段将会执行较多复制操作,导致算法效率降低。
  4. 分代收集算法

    • 现在的虚拟机垃圾收集大多采用这种方式,它根据对象的生存周期,将堆分为新生代(Young)和老年代(Tenure)。在新生代中,由于对象生存期短,每次回收都会有大量对象死去,那么这时就采用复制算法。老年代里的对象存活率较高,没有额外的空间进行分配担保,所以可以使用标记-整理 或者 标记-清除。
    • 新生代(Young)分为Eden区,From区与To区
      • 当系统创建一个对象的时候,总是在Eden区操作,当这个区满了,那么就会触发一次YoungGC,也就是年轻代的垃圾回收。一般来说这时候不是所有的对象都没用了,所以就会把还能用的对象复制到From区。
      • 这样整个Eden区就被清理干净了,可以继续创建新的对象,当Eden区再次被用完,就再触发一次YoungGC,然后呢,注意,这个时候跟刚才稍稍有点区别。这次触发YoungGC后,会将Eden区与From区还在被使用的对象复制到To区,
      • 再下一次YoungGC的时候,则是将Eden区与To区中的还在被使用的对象复制到From区。
        经过若干次YoungGC后,有些对象在From与To之间来回游荡,这时候From区与To区亮出了底线(阈值),这些家伙要是到现在还没挂掉,对不起,一起滚到(复制)老年代吧。
      • 老年代经过这么几次折腾,也就扛不住了(空间被用完),好,那就来次集体大扫除(Full GC),也就是全量回收。如果Full GC使用太频繁的话,无疑会对系统性能产生很大的影响。所以要合理设置年轻代与老年代的大小,尽量减少Full GC的操作。

64. GC垃圾收集器有哪些

  1. Serial 收集器(年轻代、单线程、复制算法)

    • 是最古老的垃圾收集器,使用复制算法,曾经是JDK1.3.1 之前新生代唯一的垃圾收集器。Serial 是一个单线程的收集器,它不但只会使用一个 CPU 或一条线程去完成垃圾收集工作,并且在进行垃圾收集的同时,必须暂停其他所有的工作线程,直到垃圾收集结束。Serial 垃圾收集器虽然在收集垃圾过程中需要暂停所有其他的工作线程,但是它简单高效,对于限定单个 CPU 环境来说,没有线程交互的开销,可以获得最高的单线程垃圾收集效率,因此 Serial垃圾收集器依然是 java 虚拟机运行在 Client 模式下默认的新生代垃圾收集器。
  2. Serial Old 收集器(老年代、单线程、标记整理算法 )

    • Serial Old 是 Serial 垃圾收集器年老代版本,它同样是个单线程的收集器,使用标记-整理算法
  3. ParNew 收集器(年轻代、多线程、复制算法)

    • ParNew 垃圾收集器其实是 Serial 收集器的多线程版本,也使用复制算法,除了使用多线程进行垃圾收集之外,其余的行为和 Serial 收集器完全一样。ParNew 垃圾收集器在垃圾收集过程中同样也要暂停所有其他的工作线程。ParNew 收集器默认开启和 CPU 数目相同的线程数,可以通过 -XX:ParallelGCThreads 参数来限制垃圾收集器的线程数。
  4. Parallel Scavenge 收集器(年轻代、多线程、复制算法)

    • Parallel Scavenge收集器也是一个并行的多线程新生代收集器,它也使用复制算法。Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标是达到一个可控制的吞吐量。
    • 虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种方式称为GC自适应的调节策略(GC Ergonomics)。自适应调节策略也是Parallel Scavenge收集器与ParNew收集器的一个重要区别。
    • JDK8 年轻代默认使用 Parallel Scavenge 进行垃圾回收
  5. Parallel Old 收集器(老年代、多线程、标记整理算法)

    • Parallel Old 收集器是Parallel Scavenge的年老代版本,使用多线程的标记-整理算法,在 JDK1.6才开始提供。在 JDK1.6 之前,新生代使用 ParallelScavenge 收集器只能搭配年老代的 Serial Old 收集器,只能保证新生代的吞吐量优先,无法保证整体的吞吐量,Parallel Old 正是为了在年老代同样提供吞吐量优先的垃圾收集器,如果系统对吞吐量要求比较高,可以优先考虑新生代 Parallel Scavenge和年老代 Parallel Old 收集器的搭配策略。
    • JDK8 老年代默认使用 Parallel old 进行垃圾回收
  6. CMS 收集器(老年代、多线程、标记清除算法)

    • 从CMS开始出现了并发收集器:指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行。而垃圾收集程序运行在另一个CPU上。
    • CMS(Concurrent mark sweep)是一种年老代垃圾收集器,其最主要目标是获取最短垃圾回收停顿时间,和其他年老代使用标记-整理算法不同,它使用多线程的标记-清除算法。最短的垃圾收集停顿时间可以为交互比较高的程序提高用户体验。
    • CMS的垃圾回收分为4个阶段:
      1. 初始标记:仅仅是标记一下GC Roots能直接关联的对象,速度快;需要STW
      2. 并发标记:进行 GC Roots 跟踪的过程,和用户线程一起工作,不需要暂停工作线程。
      3. 重新标记:是修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,停顿时间比初始标记长,比并发标记短;需要STW
      4. 并发清除:清除标记的对象,由于使用标记清楚算法可能会在收集结束时产生大量空间碎片,有可能导致没有足够大的连续空间来分配当前对象而触发一次Full GC。
    • 优点
      • 并发收集、低停顿,因此CMS收集器也被称为并发低停顿收集器。
    • 缺点
      • 会产生大量空间碎片
      • 无法处理浮动垃圾
      • 伴随程序运行自然就还会有新的垃圾不断产生。这一部分垃圾出现在标记过程之后,CMS无法再当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就被称为“浮动垃圾”
  7. G1 收集器

    • 通过把Java堆分成大小相等的多个独立区域,回收时计算出每个区域回收所获得的空间以及所需时间的经验值,根据记录两个值来判断哪个区域最具有回收价值,所以叫Garbage First(垃圾优先)。
    • G1是一种分代收集器,只有逻辑上的分代概念,物理上不分代
      • 年轻代:采用复制算法
      • 年老代:标记清除算法
      • 通过命令行参数
        • -XX:NewRatio=n来配置新生代与老年代的比例,默认为2,即比例为2:1;
        • -XX:SurvivorRatio=n则可以配置Eden与Survivor的比例,默认为8。
    • G1最大的优势就在于可预测的停顿时间模型,我们可以自己通过参数-XX:MaxGCPauseMillis来设置允许的停顿时间(默认200ms),G1会收集每个Region的回收之后的空间大小、回收需要的时间,根据评估得到的价值,在后台维护一个优先级列表,然后基于我们设置的停顿时间优先回收价值收益最大的Region。
    • 重要概念
      • Region(区域)
        • G1收集器通过把Java堆分成一个个大小相等的Region,Region是G1回收的最小单元。
          默认将整堆划分为2048个分区,可以通过参数-XX:G1HeapRegionSize=n可指定分区大小(1MB~32MB,且必须是2的幂)。如果不设定,那么G1会根据Heap大小自动决定。
        • Region分为
          • Eden Region
          • Survivor Region
          • Humongous Region
        • 当一个对象大于Region大小的50%,称为巨型对象;它就会独占一个或多个Region,巨型对象会直接在老年代分配,所占用的连续空间称为巨型分区-Humongous Region
        • G1 不会对巨型对象进行拷贝,并且回收时也会优先回收这个巨型对象

65. 什么情况下会产生StackOverflowError(栈溢出)和OutOfMemoryError(堆溢出)

  • 引发 StackOverFlowError 的常见原因有以下几种
    • 无限递归循环调用(最常见)
    • 执行了大量方法,导致线程栈空间耗尽
    • 方法内声明了海量的局部变量
    • native 代码有栈上分配的逻辑,并且要求的内存还不小,比如 java.net.SocketInputStream.read0 会在栈上要求分配一个 64KB 的缓存(64位 Linux)。
  • 引发 OutOfMemoryError的常见原因有以下几种
    • 内存中加载的数据量过于庞大,如一次从数据库取出过多数据
    • 集合类中有对对象的引用,使用完后未清空,使得JVM不能回收
    • 代码中存在死循环或循环产生过多重复的对象实体
    • 启动参数内存值设定的过小

66. 什么是线程池,线程池有哪些(创建)

线程池就是事先将多个线程对象放到一个容器中,当使用的时候就不用 new 线程而是直接去池中拿线程即可,节省了开辟子线程的时间,提高的代码执行效率

在 JDK 的 java.util.concurrent.Executors 中提供了生成多种线程池的静态方法。

1
2
3
4
5
6
7
ExecutorService newCachedThreadPool = Executors.newCachedThreadPool();

ExecutorService newFixedThreadPool = Executors.newFixedThreadPool(4);

ScheduledExecutorService newScheduledThreadPool = Executors.newScheduledThreadPool(4);

ExecutorService newSingleThreadExecutor = Executors.newSingleThreadExecutor();

然后调用他们的 execute(thread) 方法即可。

这4种线程池底层 全部是ThreadPoolExecutor对象的实现,阿里规范手册中规定线程池采用ThreadPoolExecutor自定义的,实际开发也是。

也可以使用自定义的线程池:

1
2
// 参数分别为 corePoolSize, maximumPoolSize, keepAliveTime, TimeUnit, BlockingQueue<Runnable>, threadFactory(默认), RejectedExecutionHandler(默认)
ThreadPoolExecutor myThreadPool = new ThreadPoolExecutor(60, 1000, 10000,TimeUnit.MINUTES, new ArrayBlockingQueue<>(10000));

解释:ThreadPoolExecutor 的参数含义及源码执行流程_executors.defaultthreadfactory()-CSDN博客

  • corePoolSize: 表示线程池的常驻核心线程数。如果设置为 0,则表示在没有任何任务时,销毁线程池;如果大于 0,即使没有任务时也会保证线程池的线程数量等于此值。但需要注意,此值如果设置的比较小,则会频繁的创建和销毁线程;如果设置的比较大,则会浪费系统资源

  • maximumPoolSize: 表示线程池在任务最多时,最大可以创建的线程数。官方规定此值必须大于 0,也必须大于等于 corePoolSize,此值只有在任务比较多,且不能存放在任务队列时,才会用到。

  • keepAliveTime: 表示线程的存活时间,当线程池空闲时并且超过了此时间,多余的线程就会销毁,直到线程池中的线程数量销毁的等于 corePoolSize 为止,如果 maximumPoolSize 等于 corePoolSize,那么线程池在空闲的时候也不会销毁任何线程。

  • TimeUnit: 表示存活时间的单位,它是配合 keepAliveTime 参数共同使用的。

  • workQueue: 表示线程池执行的任务队列,当线程池的所有线程都在处理任务时,如果来了新任务就会缓存到此任务队列中排队等待执行。

  • threadFactory: 表示线程的创建工厂,此参数一般用的比较少,我们通常在创建线程池时不指定此参数,它会使用默认的线程创建工厂的方法来创建线程,源代码如下:

    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
    public ThreadPoolExecutor(int corePoolSize,
    int maximumPoolSize,
    long keepAliveTime,
    TimeUnit unit,
    BlockingQueue<Runnable> workQueue) {
    // Executors.defaultThreadFactory() 为默认的线程创建工厂
    this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
    Executors.defaultThreadFactory(), defaultHandler);
    }
    public static ThreadFactory defaultThreadFactory() {
    return new DefaultThreadFactory();
    }
    // 默认的线程创建工厂,需要实现 ThreadFactory 接口
    static class DefaultThreadFactory implements ThreadFactory {
    private static final AtomicInteger poolNumber = new AtomicInteger(1);
    private final ThreadGroup group;
    private final AtomicInteger threadNumber = new AtomicInteger(1);
    private final String namePrefix;

    DefaultThreadFactory() {
    SecurityManager s = System.getSecurityManager();
    group = (s != null) ? s.getThreadGroup() :
    Thread.currentThread().getThreadGroup();
    namePrefix = "pool-" +
    poolNumber.getAndIncrement() +
    "-thread-";
    }
    // 创建线程
    public Thread newThread(Runnable r) {
    Thread t = new Thread(group, r,
    namePrefix + threadNumber.getAndIncrement(),
    0);
    if (t.isDaemon())
    t.setDaemon(false); // 创建一个非守护线程
    if (t.getPriority() != Thread.NORM_PRIORITY)
    t.setPriority(Thread.NORM_PRIORITY); // 线程优先级设置为默认值
    return t;
    }
    }
  • RejectedExecutionHandler: 表示指定线程池的拒绝策略,当线程池的任务已经在缓存队列 workQueue 中存储满了之后,并且不能创建新的线程来执行此任务时,就会用到此拒绝策略,它属于一种限流保护的机制。

67. execute() 与 submit() 有何区别

execute() 和 submit() 都是用来执行线程池任务的,它们最主要的区别是,submit() 方法可以接收线程池执行的返回值,而 execute() 不能接收返回值

68. 线程池的拒绝策略

当线程池中的任务队列已经被存满,再有任务添加时会先判断当前线程池中的线程数是否大于等于线程池的最大值,如果是,则会触发线程池的拒绝策略。

Java 自带的拒绝策略有 4 种:

  • AbortPolicy,终止策略,线程池会抛出异常并终止执行,它是默认的拒绝策略;
  • CallerRunsPolicy,把任务交给当前线程来执行;
  • DiscardPolicy,忽略此任务(最新的任务);
  • DiscardOldestPolicy,忽略最早的任务(最先加入队列的任务)。

69. 自定义拒绝策略

自定义拒绝策略只需要新建一个 RejectedExecutionHandler 对象,然后重写它的 rejectedExecution() 方法即可,如下代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ThreadPoolExecutor executor = new ThreadPoolExecutor(1, 3, 10,
TimeUnit.SECONDS, new LinkedBlockingQueue<>(2),
new RejectedExecutionHandler() { // 添加自定义拒绝策略
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
// 业务处理方法
System.out.println("执行自定义拒绝策略");
}
});
for (int i = 0; i < 6; i++) {
executor.execute(() -> {
System.out.println(Thread.currentThread().getName());
});
}

以上代码执行的结果如下:

1
2
3
4
5
6
执行自定义拒绝策略
pool-1-thread-2
pool-1-thread-3
pool-1-thread-1
pool-1-thread-1
pool-1-thread-2

70. ThreadPoolExecutor 扩展

ThreadPoolExecutor 的扩展主要是通过重写它的 beforeExecute() 和 afterExecute() 方法实现的,我们可以在扩展方法中添加日志或者实现数据统计,比如统计线程的执行时间,如下代码所示:

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
public class ThreadPoolExtend {
public static void main(String[] args) throws ExecutionException, InterruptedException {
// 线程池扩展调用
MyThreadPoolExecutor executor = new MyThreadPoolExecutor(2, 4, 10,
TimeUnit.SECONDS, new LinkedBlockingQueue());
for (int i = 0; i < 3; i++) {
executor.execute(() -> {
Thread.currentThread().getName();
});
}
}
/**
* 线程池扩展
*/
static class MyThreadPoolExecutor extends ThreadPoolExecutor {
// 保存线程执行开始时间
private final ThreadLocal<Long> localTime = new ThreadLocal<>();
public MyThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime,
TimeUnit unit, BlockingQueue<Runnable> workQueue) {
super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue);
}
/**
* 开始执行之前
* @param t 线程
* @param r 任务
*/
@Override
protected void beforeExecute(Thread t, Runnable r) {
Long sTime = System.nanoTime(); // 开始时间 (单位:纳秒)
localTime.set(sTime);
System.out.println(String.format("%s | before | time=%s",
t.getName(), sTime));
super.beforeExecute(t, r);
}
/**
* 执行完成之后
* @param r 任务
* @param t 抛出的异常
*/
@Override
protected void afterExecute(Runnable r, Throwable t) {
Long eTime = System.nanoTime(); // 结束时间 (单位:纳秒)
Long totalTime = eTime - localTime.get(); // 执行总时间
System.out.println(String.format("%s | after | time=%s | 耗时:%s 毫秒",
Thread.currentThread().getName(), eTime, (totalTime / 1000000.0)));
super.afterExecute(r, t);
}
}
}

以上程序的执行结果如下所示:

1
2
3
4
5
6
pool-1-thread-1 | before | time=4570298843700
pool-1-thread-2 | before | time=4570298840000
pool-1-thread-1 | after | time=4570327059500 | 耗时:28.2158 毫秒
pool-1-thread-2 | after | time=4570327138100 | 耗时:28.2981 毫秒
pool-1-thread-1 | before | time=4570328467800
pool-1-thread-1 | after | time=4570328636800 | 耗时:0.169 毫秒

71. 常见线程安全的并发容器有哪些

  1. CopyOnWriteArrayList、CopyOnWriteArraySet、ConcurrentHashMap
  2. CopyOnWriteArrayList、CopyOnWriteArraySet 采用写时复制实现线程安全
  3. ConcurrentHashMap 采用分段锁的方式实现线程安全

72. synchronized底层实现是什么 lock底层是什么 有什么区别

  • Synchronized原理:

    • 方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有 monitor(虚拟机规范中用的是管程一词),然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放 monitor。

    • 代码块的同步是利用 monitorenter 和 monitorexit 这两个字节码指令。它们分别位于同步代码块的开始和结束位置。当jvm执行到 monitorenter 指令时,当前线程试图获取 monitor 对象的所有权,如果未加锁或者已经被当前线程所持有,就把锁的计数器+1;当执行 monitorexit 指令时,锁计数器-1;当锁计数器为0时,该锁就被释放了。如果获取 monitor 对象失败,该线程则会进入阻塞状态,直到其他线程释放锁。

  • Lock原理:

    • Lock的存储结构:一个int类型状态值(用于锁的状态变更),一个双向链表(用于存储等待中的线程)
    • Lock获取锁的过程:本质上是通过CAS来获取状态值修改,如果当场没获取到,会将该线程放在线程等待链表中。
    • Lock释放锁的过程:修改状态值,调整等待链表。
    • Lock大量使用CAS+自旋。因此根据CAS特性,lock建议使用在低锁冲突的情况下。
  • Lock与synchronized的区别:

    • Lock的加锁和解锁都是由java代码配合native方法(调用操作系统的相关方法)实现的,而synchronize的加锁和解锁的过程是由JVM管理的
    • 当一个线程使用synchronize获取锁时,若锁被其他线程占用着,那么当前只能被阻塞,直到成功获取锁。而Lock则提供超时锁和可中断等更加灵活的方式,在未能获取锁的 条件下提供一种退出的机制。
    • 一个锁内部可以有多个Condition实例,即有多路条件队列,而synchronize只有一路条件队列;同样Condition也提供灵活的阻塞方式,在未获得通知之前可以通过中断线程以 及设置等待时限等方式退出条件队列。
    • synchronize对线程的同步仅提供独占模式,而Lock即可以提供独占模式,也可以提供共享模式
  • synchronized Lock
    synchronized
    自动加锁和释放锁 需要手动调用unlock方法释放锁
    jvm层面的锁 API层面的锁
    非公平锁 可以选择公平或者非公平锁
    锁是一个对象,并且锁的信息保存在了对象中 代码中通过int类型的state标识
    有一个锁升级的过程

73. 了解ConcurrentHashMap吗 为什么性能比HashTable高,说下原理

  • ConcurrentHashMap是线程安全的Map容器,JDK8之前,ConcurrentHashMap使用锁分段技术,将数据分成一段段存储,每个数据段配置一把锁,即segment类,这个类继承ReentrantLock来保证线程安全,JKD8的版本取消Segment这个分段锁数据结构,底层也是使用Node数组+链表+红黑树,从而实现对每一段数据就行加锁,也减少了并发冲突的概率。

  • hashtable类基本上所有的方法都是采用synchronized进行线程安全控制,高并发情况下效率就降低 ,ConcurrentHashMap是采用了分段锁的思想提高性能,锁粒度更细化

74. 了解volatile关键字不

  • volatile 是 Java 提供的最轻量级的同步机制,保证了共享变量的可见性,被 volatile 关键字修饰的变量,如果值发生了变化,其他线程立刻可见,避免出现脏读现象。
  • volatile 禁止了指令重排,可以保证程序执行的有序性,但是由于禁止了指令重排,所以 JVM 相关的优化没了,效率会偏弱

75. synchronized和volatile有什么区别

  • volatile 本质是告诉 JVM 当前变量在寄存器中的值是不确定的,需要从主存中读取,synchronized 则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
  • volatile 仅能用在变量级别,而 synchronized 可以使用在变量、方法、类级别。
  • volatile 仅能实现变量的修改可见性,不能保证原子性;而 synchronized 则可以保证变量的修改可见性和原子性。
  • volatile 不会造成线程阻塞,synchronized 可能会造成线程阻塞。
  • volatile 标记的变量不会被编译器优化,synchronized 标记的变量可以被编译器优化。

76. Java类加载过程

  1. 加载,加载时类加载的第一个过程,在这个阶段,将完成以下三件事情:

    • 通过一个类的全限定名获取该类的二进制流。

    • 将该二进制流中的静态存储结构转化为方法去运行时数据结构。

    • 在内存中生成该类的 Class 对象,作为该类的数据访问入口。

  2. 验证,验证的目的是为了确保 Class 文件的字节流中的信息不会危害到虚拟机。在该阶段主要完成以下四种验证:

    • 文件格式验证:验证字节流是否符合Class文件的规范,如主次版本号是否在当前虚拟机范围内,常量池中的常量是否有不被支持的类型。

    • 元数据验证:对字节码描述的信息进行语义分析,如这个类是否有父类,是否集成了不被继承的类等。

    • 字节码验证:是整个验证过程中最复杂的一个阶段,通过验证数据流和控制流的分析,确定程序语义是否正确,主要针对方法体的验证。如:方法中的类型转换是否正确,跳转指令是否正确等。

    • 符号引用验证:这个动作在后面的解析过程中发生,主要是为了确保解析动作能正确执行。

  3. 准备

    • 准备阶段是为类的静态变量分配内存并将其初始化为默认值,这些内存都将在方法区中进行分配。准备阶段不分配类中的实例变量的内存,实例变量将会在对象实例化时随着对象一起分配在Java堆中。
  4. 解析

    • 该阶段主要完成符号引用到直接引用的转换动作。解析动作并不一定在初始化动作完成之前,也有可能在初始化之后。
  5. 初始化

    • 初始化时类加载的最后一步,前面的类加载过程,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的Java程序代码。

77. 什么是类加载器,类加载器有哪些

  • 类加载器就是把类文件加载到虚拟机中,也就是说通过一个类的全限定名来获取描述该类的二进制字节流。

  • 主要有以下四种类加载器

    1. 启动类加载器(Bootstrap ClassLoader): 用来加载java核心类库,无法被java程序直接引用

    2. 扩展类加载器(extension class loader): 它用来加载 Java 的扩展库。Java 虚拟机的实现会提供一个扩展库目录。该类加载器在此目录里面查找并加载 Java 类

    3. 系统类加载器 (system class loader) 也叫应用类加载器:它根据 Java 应用的类路径(CLASSPATH)来加载 Java 类。一般来说,Java 应用的类都是由它来完成加载的。可以通过 ClassLoader.getSystemClassLoader()来获取它

    4. 用户自定义类加载器,通过继承 java.lang.ClassLoader类的方式实现

  • 什么时候会使用到加载器?java中的加载器是按需加载,什么时候用到,什么时候加载

    • new对象的时候
    • 访问某个类或者接口的静态变量,或者对该静态变量赋值时
    • 调用类的静态方法时
    • 反射
    • 初始化一个类的子类时,其父类首先会被加载
    • JVM启动时标明的启动类,也就是文件名和类名相同的那个类

78. 简述java内存分配与回收策略以及Minor GC和Major GC(full GC)

  • 内存分配

    1. 栈区:栈分为java虚拟机栈和本地方法栈

    2. 堆区:堆被所有线程共享区域,在虚拟机启动时创建,唯一目的存放对象实例。堆区是gc的主要区域,通常情况下分为两个区块年轻代和年老代。更细一点年轻代又分为Eden区,主要放新创建对象,From survivor 和 To survivor 保存gc后幸存下的对象,默认情况下各自占比 8:1:1。

    3. 方法区:被所有线程共享区域,用于存放已被虚拟机加载的类信息,常量,静态变量等数据。被Java虚拟机描述为堆的一个逻辑部分。习惯是也叫它永久代(permanment generation)

    4. 程序计数器:当前线程所执行的行号指示器。通过改变计数器的值来确定下一条指令,比如循环,分支,跳转,异常处理,线程恢复等都是依赖计数器来完成。线程私有的。

  • 回收策略以及Minor GC和Major GC

    1. 对象优先在堆的Eden区分配
    2. 大对象直接进入老年代
    3. 长期存活的对象将直接进入老年代
    4. 当Eden区没有足够的空间进行分配时,虚拟机会执行一次Minor GC,Minor GC通常发生在新生代的Eden区,在这个区的对象生存期短,往往发生GC的频率较高,回收速度比较快;Full Gc/Major GC 发生在老年代,一般情况下,触发老年代GC的时候不会触发Minor GC,但是通过配置,可以在Full GC之前进行一次Minor GC这样可以加快老年代的回收速度。

79. 死锁的四个必要条件

  • 死锁的四个必要条件通常被称为 互斥条件、请求和保持条件、不可剥夺条件 和 环路等待条件。
  1. 互斥条件:至少有一个资源必须处于非共享模式,即一次只允许一个进程使用。如果其他进程尝试访问该资源,则必须等待,直到资源被释放。

  2. 请求和保持条件:一个进程至少持有一个资源,同时正在等待获取其他进程持有的更多资源。这意味着进程在等待时不会释放其已有的资源。

  3. 不可剥夺条件:一个进程持有的资源不能被剥夺,即使这些资源正在被进程使用,也只能在进程使用完毕后由自己释放。

  4. 环路等待条件:存在一种情况,即一系列的进程等待它们的前一个进程释放资源,形成了一个等待环路。这种情况下,没有一个进程能够继续执行,因为它们都在等待其他进程释放资源。

80. 避免死锁的方法

  1. 要注意加锁顺序,保证每个线程按同样的顺序进行加锁

  2. 要注意加锁时限,可以针对锁设置一个超时时间

  3. 要注意死锁检查,这是一种预防机制,确保在第一时间发现死锁并进行解决

81. Java到底是值传递还是引用传递?

形参和实参

形参: 就是形式参数,用于定义方法的时候使用的参数,是用来接收调用者传递的参数的。

实参: 就是实际参数,用于调用时传递给方法的参数。实参在传递给别的方法之前是要被预先赋值的。

值传递和引用传递

值传递: 是指在调用方法时,将实际参数拷贝一份传递给方法,这样在方法中修改形式参数时,不会影响到实际参数。

引用传递: 也叫地址传递,是指在调用方法时,将实际参数的地址传递给方法,这样在方法中对形式参数的修改,将影响到实际参数。

Java参数传递中,不管传递的是基本数据类型还是引用类型,都是值传递

当传递基本数据类型,比如原始类型(int、long、char等)、包装类型(Integer、Long、String等),实参和形参都是存储在不同的栈帧内,修改形参的栈帧数据,不会影响实参的数据。

当传参的引用类型,形参和实参指向同一个地址的时候,修改形参地址的内容,会影响到实参。当形参和实参指向不同的地址的时候,修改形参地址的内容,并不会影响到实参。

举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Demo {
public static void main(String[] args) {
User user = new User();
user.setId(0);
}

public static void update(User user) {
user = new User();
user.setId(1);
}
}
// 结果是user的id为0,因为new了之后user就不是原来传入的地址了

public class Demo {
public static void main(String[] args) {
User user = new User();
user.setId(0);
}

public static void update(User user) {
user.setId(1);
}
}
// 结果是user的id为1,因为这两个user指向的内存地址都是相同的

82. 运行时类型和编译类型

1
Person laoDa = new Man();

编译时类型是 Person,运行时类型是 Man

Object 类的 getClass() 方法是获取的运行时类型

83. 实现深拷贝的方法

参考:死磕Java面试:深拷贝与浅拷贝的实现原理 - 掘金 (juejin.cn)

  1. 实现 cloneable 接口并重写 clone 方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    @Getter
    @Setter
    public class Person implements Cloneable {
    private String name;
    private Body body;

    @Override
    public Person clone() throws CloneNotSupportedException {
    Person person = (Person) super.clone();
    // Person对象中所有引用类型属性都要执行clone方法
    person.setBody(person.getBody().clone());
    return person;
    }
    }
  2. 使用 JSON 字符串转换

    • 先把 user 对象转换成 json 字符串
    • 再把 json 字符串转换成 user 对象
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    public class Demo {
    public static void main(String[] args) throws CloneNotSupportedException {
    // 1. 创建用户对象
    User user1 = new User();
    user1.setName("张三");
    Job job1 = new Job();
    job1.setContent("开发");
    user1.setJob(job1);

    //// 2. 拷贝用户对象
    User user2 = JSON.parseObject(JSON.toJSONString(user1), User.class);
    user2.setName("李四");
    Job job2 = user2.getJob();
    job2.setContent("测试");

    // 3. 输出结果
    System.out.println("user原对象= " + JSON.toJSONString(user1));
    System.out.println("user拷贝对象= " + JSON.toJSONString(user2));
    }
    }
    // 输出
    //user原对象= {"name":"一灯架构","job":{"content":"开发"}}
    //user拷贝对象= {"name":"张三","job":{"content":"测试"}}
  3. 集合实现深拷贝

    • 即使用构造方法
    1
    2
    3
    4
    5
    6
    7
    8
    9
    public class Demo {
    public static void main(String[] args) throws CloneNotSupportedException {
    // 1. 创建原对象
    List<User> userList = new ArrayList<>();

    // 2. 创建深拷贝对象
    List<User> userCopyList = new ArrayList<>(userList);
    }
    }

84. 获取class对象的方式的主要有三种

  • 根据类名:类名.class
  • 根据对象:对象.getClass()
  • 根据全限定类名:Class.forName(全限定类名)

85. getName、getCanonicalName与getSimpleName的区别

  • getSimpleName:只获取类名
  • getName:类的全限定名,jvm中Class的表示,可以用于动态加载Class对象,例如Class.forName。
  • getCanonicalName:返回更容易理解的表示,主要用于输出(toString)或log打印,大多数情况下和getName一样,但是在内部类、数组等类型的表示形式就不同了。

86. 获取成员变量列表

  • 获取所有 public 的成员变量信息使用 .getFields()

    1
    2
    3
    4
    5
    Class clazz = example.class;
    Field[] fs = clazz.getFields();
    for (Field f : fs) {
    Class fieldType = field.getType();
    }
  • 获取该类自己声明的成员变量的信息使用 .getDeclaredFields()

    1
    2
    3
    4
    5
    Class clazz = example.class;
    Field[] fs = clazz.getDeclaredFields();
    for (Field f : fs) {
    Class fieldType = field.getType();
    }

87. 获取构造函数信息

  • 获取构造函数使用 .getConstructors()

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    Class clazz = example.class;
    Constructors[] constructors = clazz.getConstructors();
    for (Constructors c : constructors) {
    System.out.println(c.getName() + "(");
    // 获取构造函数的参数列表
    Class[] paramTypes = c.getParameterTypes();
    for (Class paramClass : paramTypes) {
    System.out.println(paramClass.getName() + ",");
    }
    }
    // 输出类似于: java.lang.String(int, int, java.lang.String)

88. 方法反射

  • 获取方法基本步骤

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    // 1. 获得类
    Example e = new Example();
    Class clazz = e.getClass();

    // 2. 获取方法,由名称和参数列表来决定
    try {
    // 2 种传参方式,数组或直接写
    Method m = clazz.getMethod("testMethod", new Class[]{int.class, int.class});
    Method m = clazz.getMethod("testMethod", int.class, int.class);

    // 方法的反射,也有 2 种转参方式
    m.invoke(e, new Object[]{1, 2});
    m.invoke(e, 1, 2);
    } catch (Exception e) {
    e.printStackTrace();
    }

89. JDK 动态代理

深入理解 Java 反射和动态代理 | JAVACORE (dunwu.github.io)

Java 动态代理基于经典代理模式,引入了一个 InvocationHandlerInvocationHandler 负责统一管理所有的方法调用。

动态代理步骤:

  1. 获取 RealSubject 上的所有接口列表;
  2. 确定要生成的代理类的类名,默认为:com.sun.proxy.$ProxyXXXX
  3. 根据需要实现的接口信息,在代码中动态创建 该 Proxy 类的字节码;
  4. 将对应的字节码转换为对应的 class 对象;
  5. 创建 InvocationHandler 实例 handler,用来处理 Proxy 所有方法调用;
  6. Proxy 的 class 对象 以创建的 handler 对象为参数,实例化一个 proxy 对象。
InvocationHandler 接口

InvocationHandler 接口定义:

1
2
3
4
public interface InvocationHandler {
public Object invoke(Object proxy, Method method, Object[] args)
throws Throwable;
}

参数说明:

  • proxy - 代理的真实对象。
  • method - 所要调用真实对象的某个方法的 Method 对象
  • args - 所要调用真实对象某个方法时接受的参数
Proxy 类

Proxy 这个类的作用就是用来动态创建一个代理对象的类,它提供了许多的方法,但是我们用的最多的就是 newProxyInstance 这个方法:

1
public static Object newProxyInstance(ClassLoader loader, Class<?>[] interfaces,  InvocationHandler h)  throws IllegalArgumentException

这个方法的作用就是得到一个动态的代理对象。

参数说明:

  • loader - 一个 ClassLoader 对象,定义了由哪个 ClassLoader 对象来对生成的代理对象进行加载。
  • interfaces - 一个 Class<?> 对象的数组,表示的是我将要给我需要代理的对象提供一组什么接口,如果我提供了一组接口给它,那么这个代理对象就宣称实现了该接口(多态),这样我就能调用这组接口中的方法了
  • h - 一个 InvocationHandler 对象,表示的是当我这个动态代理对象在调用方法的时候,会关联到哪一个 InvocationHandler 对象上
小结

代理类与委托类实现同一接口,主要是通过代理类实现 InvocationHandler 并重写 invoke 方法来进行动态代理的,在 invoke 方法中将对方法进行处理。

JDK 动态代理特点:

  • 优点:相对于静态代理模式,不需要硬编码接口,代码复用率高。
  • 缺点:强制要求代理类实现 InvocationHandler 接口。

90. CGLIB 动态代理

深入理解 Java 反射和动态代理 | JAVACORE (dunwu.github.io)

CGLIB 提供了与 JDK 动态代理不同的方案。很多框架,例如 Spring AOP 中,就使用了 CGLIB 动态代理。

CGLIB 底层,其实是借助了 ASM 这个强大的 Java 字节码框架去进行字节码增强操作。

CGLIB 动态代理的工作步骤:

  • 生成代理类的二进制字节码文件;
  • 加载二进制字节码,生成 Class 对象( 例如使用 Class.forName() 方法 );
  • 通过反射机制获得实例构造,并创建代理类对象。

CGLIB 动态代理特点:

优点:使用字节码增强,比 JDK 动态代理方式性能高。可以在运行时对类或者是接口进行增强操作,且委托类无需实现接口。

缺点:不能对 final 类以及 final 方法进行代理。

91.什么是注解

从本质上来说,注解是一种标签,其实质上可以视为一种特殊的注释,如果没有解析它的代码,它并不比普通注释强。

解析一个注解往往有两种形式:

  • 编译期直接的扫描 - 编译器的扫描指的是编译器在对 java 代码编译字节码的过程中会检测到某个类或者方法被一些注解修饰,这时它就会对于这些注解进行某些处理。这种情况只适用于 JDK 内置的注解类。
  • 运行期的反射 - 如果要自定义注解,Java 编译器无法识别并处理这个注解,它只能根据该注解的作用范围来选择是否编译进字节码文件。如果要处理注解,必须利用反射技术,识别该注解以及它所携带的信息,然后做相应的处理。

注解有许多用途:

  • 编译器信息 - 编译器可以使用注解来检测错误或抑制警告。
  • 编译时和部署时的处理 - 程序可以处理注解信息以生成代码,XML 文件等。
  • 运行时处理 - 可以在运行时检查某些注解并处理。

凡事有得必有失,注解技术同样如此。使用注解也有一定的代价:

  • 显然,它是一种侵入式编程,那么,自然就存在着增加程序耦合度的问题。
  • 自定义注解的处理需要在运行时,通过反射技术来获取属性。如果注解所修饰的元素是类的非 public 成员,也可以通过反射获取。这就违背了面向对象的封装性。
  • 注解所产生的问题,相对而言,更难以 debug 或定位。

注解可以应用于类、字段、方法和其他程序元素的声明。

92. Java 中的 SPI

Java SPI 机制详解 | JavaGuide

SPI 即 Service Provider Interface ,字面意思就是:“服务提供者的接口”,我的理解是:专门提供给服务提供者或者扩展框架功能的开发者去使用的一个接口。

SPI 将服务接口和具体的服务实现分离开来,将服务调用方和服务实现者解耦,能够提升程序的扩展性、可维护性。修改或者替换服务实现并不需要修改调用方。

我们按照规定将要暴露对外使用的具体实现类在 META-INF/services/ 文件下声明。

Java 中的 SPI 机制就是在每次类加载的时候会先去找到 class 相对目录下的 META-INF 文件夹下的 services 文件夹下的文件,将这个文件夹下面的所有文件先加载到内存中,然后根据这些文件的文件名和里面的文件内容找到相应接口的具体实现类,找到实现类后就可以通过反射去生成对应的对象,保存在一个 list 列表里面,所以可以通过迭代或者遍历的方式拿到对应的实例对象,生成不同的实现。

所以会提出一些规范要求:文件名一定要是接口的全类名,然后里面的内容一定要是实现类的全类名,实现类可以有多个,直接换行就好了,多个实现类的时候,会一个一个的迭代加载。


Java(三)
http://cloudyw.cn/2024/05/04/Java-三/
作者
cloudyW
发布于
2024年5月4日
许可协议