倚楼听风雨
淡看江湖路

Java 十四道由浅入深的笔面试题第五期 详细解析

一、hashCode 相等两个类一定相等吗?equals 呢?

在设计之初,hashCode 与 equals 就是相辅相成的,单独拿出一个方法对对象相等的讨论都是耍流氓。

hashCode 和 equals 这两个方法协同工作用来判断两个对象是否相等,在对象相等判断的底层实现中, 首先调用的是 Object.hashCode 生成哈希值,如果哈希 hsahCode 不同,则对象肯定不同,直接返回不相等的结果。但是我们知道哈希方法总是会存在哈希冲突的情况,此种情况夏就需要使用 equals 方法做进一步判断。

所以在 Java 中,就有如下两条特性:

  • 如果两个对象的 equals 的结果是相等的,则两个对象的 hsahCode 的返回结果也必须是相同的。
  • 任何时候覆写 equals,都必须同时覆写 hashCode

注意:尽量避免通过实例对象引用来调用 equals 方法,否则容易抛出 NPE 空指针异常问题,推荐使用 Objects.equals。

二、介绍一下集合框架

Java 十四道由浅入深的笔面试题第五期的图片-高老四博客 第1张

图源《码出高效》,侵删

简单的概括一下这张图,自此以后希望你熟记关于 Java 中关于集合的所有相关类或者接口。

首先集合大致可以分为 Collection 接口和 Map 接口;

AbstractMap、Hashtable 直接实现了 Map 接口,其中 Hashtable 基本被抛弃了,没有太多的科研究性。AbstractMap 作为抽象类是对 Map 集合的丰富工具。ConcurrentMap、SortedMap 都继承了 Map 接口以便于特定集合作用的一些方法声明;

接着,在 SortedMap 接口下,NavigableMap 继承了它,NavigableMap 有两个主要的实现,分别是 ConcurrentSkipListMap(这里还涉及一个 ConcurrentNavigableMap,这个接口继承 ConcurrentMap,其实 ConcurrentSkipListMap 继承的就是这个并发类,所以这里我觉得孤尽老师这个类标注的不是很准确) 和 TreeMap,同时这两个类都继承了 AbstractMap;

AbstractMap 作为 Map 接口丰富抽象工具类,很多其他实现类都是通过继承 AbstractMap 间接实现 Map 接口的,比如上述的 TreeMap,还有我们熟悉的 HashMap、ConcurrentHashMap 等,其中 LinkedashMap 作为 HashMap 子类同时也自己实现了 Map 接口。

剩下的集合均在 Collection(它也有一个 AbstractCollection) 接口下,包括 List、Set 和 Queue,Collection 接口又继承了 Iterable 接口,从而实现各种数据结构的遍历操作;

Set 接口主要设计为无序不可重复集合,同样有一个 AbstractSet 抽象类作为 Set 的丰富,下面的 TreeSet、HashSet(下面有LinkedHashSet) 均通过继承此抽象类间接实现 Set 接口。此外 SortedSet 直接继承 Set 接口,设计出了 NavigableSet(它也继承了 AbstractSet,下面有 ConcurrentSkipListSet);

Queue 接口主要设计为队列,最主要的就是 BlockingDeque 阻塞队列接口的实现,LinkedBlockingDeque、SynchronousQueue、ArrayBlockingQueue、PriorityBlockingQueue、DelayQueue 均是对 BlockingDeque 的实现,同时他们也继承了 AbstractQueue,同时,Queue 接口下面还有一个 Deque,实现了双端队列;

List 接口设计为有序可重复集合。同样,也有一个 AbstractList 抽象类,ArrayList、Vector(下面包含 Stack)、AbstractSequentialList 等都实现了这个抽象类间接实现 List 接口,AbstractSequentialList 下面就是 LinkedList 的实现,同时 LinkedList 也实现了 Deque 接口。在并发类当中,也有如 CopyOnWriteArrayList 这样的并发集合类直接实现 List 接口。

三、线程池的构造都需要什么参数?底层如何实现?

详细解析可以参考一下老四文末的相关阅读文章之线程池那一篇。线程池的构建主要参数分别是:

  • corePollSize 常驻核心线程数
  • maximumPoolSize 线程池能够容纳同时执行的最大线程数
  • keepAliveTime 线程池中的现成空闲时间
  • unit 时间单位
  • workQueue 缓存队列
  • threadFactory 线程工厂,用来生产一组相同任务的线程
  • handler 执行拒绝策略的对象

线程池底层实现的步骤及思路

  1. 如果当前运行的线程少于 corePoolSize(常驻核心线程数),则创建新线程来执行任务(注意,执行这一步骤需要获取全局锁)
  2. 如果运行的线程等于或多于 corePoolSize,则将任务加入 BlockingQueue 队列
  3. 如果队列已满,则创建新的线程来处理任务(注意,执行这一步骤需要获取全局锁)
  4. 如果创建新线程将使当前运行的线程超出 maximumPoolSize(线程池能够容纳同时执行的最大线程数),任务将被拒绝,并调用 RejectedExecutionHandler.rejectedExecution() 方法执行拒绝策略

四、synchronized 与 Lock 的区别?

与其说 synchronized 与 Lock 的区别倒不如说他俩作为同级别,都是 Java 中实现锁的两种不同的方式。在以 Lock 接口为核心的并发包设计中,是不借助 synchronized 关键字实现锁的控制的,核心是利用 volatile 的可见性,其中 AbstractQueueSynchronizer(AQS)作为 Java 并发包实现同步的基础工具。

而 synchronized 关键字是另一种加锁的实现方式,可以加在类上,加在方法上实现同步。它的底层实现是依靠 JVM 对对象的监视锁进行加解锁的判断,监视锁是 Java 对象默认的隐藏字段,synchronized 加锁成功就说明获取了这个对象的监视锁的持有权限,从而进行加解锁的操作。

五、ThreadLocal 的作用及底层实现?

ThreadLocal 的主要作用

ThreadLocal 的设计初衷是在线程并发时,解决变量共享问题,因为如果不这样的话,线程内的变量就需要靠方法之间的返回值和参数来传递,这样很容易造成数据耦合。所以设计一个类,可以用于在同一个线程内,跨类、跨方法来传递数据解决问题。

ThreadLocal 通常使用 private static 来修饰,但是要注意 ThreadLocal 无法解决共享对象的更新问题。所以 ThreadLocal 使用不当的情况下存在脏数据和内存泄漏的问题。

ThreadLocal 的底层实现

至于底层实现就需要你好好的掌握一下源码了。这里说一下 ThreadLocal 的大致实现思路及使用方法。

从使用方法说起,一般 ThreadLocal 对象的实例化需要重写它的 initialValue 方法,比如设置 SimpleDateFormat对象让线程单独拥有。

在 Thread 类中, 有一个 ThreadLocalMap 属性,这个属性的赋值靠得就是 ThreadLocal 中的内部静态 ThreadLocalMap 来实现的。而这个 ThreadLocalMap 核心的实现了 ThreadLocal 的 get()、set()、remove() 三个操作。在 ThreadLocalMap 中还有一个内部静态类 Entry,他是继承自 WeakReference 虚引用,key 代表的是 ThreadLocal 对象,value 代表是本地线程独对象,所以 Entry 负责 ThreadLocal 的垃圾回收,持有 ThreadLocal 对象持有的变量值,从而实现对共享变量的管理。

这个弱引用的特点是当对象为 null 的时候会被 Young GC 及时回收,不过我们需要注意的是 ThreadLocal 对象生命周期会因为 static 的限制而不会随着线程的结束而结束,所以这里的弱引用其实没有达到想要的设计效果,我们在使用的过程中还是要乖乖的使用 remove() 操作进行及时的清理操作。

参考《码出高效》,列举一下 Thread、ThreadLocal、ThreadLocalMap 以及 Entry 之间的关系和作用:

  1. 1 个 Thread 有且仅有 1 个 ThreadLocalMap 对象。
  2. 1 个 Entry 对象的 key 弱引用指向 1 个 ThreadLocal 对象。
  3. 1 个 ThreadLocalMap 对象存储多个 Entry 对象。
  4. 1 个 ThreadLocal 对象可以被多个线程所共享。
  5. ThreadLocal 对象不持有 Value,Value 由线程的 Entry 对象持有。

六、volitile 的作用及工作原理?

参考文末相关阅读之浅析 volatile 关键字的那一篇文章。

七、CAS 的实现?

CAS 全称「Compare-and-Swap」,翻译为比较并交换,执行 CAS 的过程还有一个「Compare-and-Set」的操作,都是 CAS 体系。说白了就是对原子操作的「读-改-写」一种基于硬件的并发支持。

CAS 的底层实现就是三个操作数,约定一个原子操作规则。即:

  • 需要读写的内存位置 V
  • 进行比较的值 A
  • 要写入的新值 B

约定「当且仅当 V = A,CAS 通过原子方式用 新值 B 更新 V 的值,即 V = B」,否则就会返回 V 原有的实际值(这个过程就是 Compare-and-Set)。

在 java 5.0 之后就已经支持 CAS 指令,并广泛的应用于 J.U.C 并发包中,该包中的 atomic 定义的就是一些原子变量类,这些变量类是很多并发安全类实现的基础。

八、JVM 的内存模型(内存布局)?垃圾回收器都有哪些?

注意,这类的问题要分清概念,如果概念上说「Java 的内存模型」针对的是 Happens-Before 关系的角度来说,这是一种偏序关系,保证执行操作 B 的线程看到操作 A 的结果,通过对变量的读写操作、监视器的加锁与释放操作、线程的启动和合并操作等来定义的。

如果讨论 JVM 的内存模型,则指的是 Java 虚拟机的内存布局,基于 JDK8 来讲,JVM 的内存布局如下图所示:

Java 十四道由浅入深的笔面试题第五期的图片-高老四博客 第2张

图源《码出高效》,侵删

  • 本地方法栈:主要通过 JNI 调用 CPU提供必要的服务。
  • 程序计数器:主要用作线程执行指令的行号指示器,线程的执行和恢复的依赖。
  • 虚拟机栈:描述 Java 方法执行的内存区域,栈中的元素组成用于支持虚拟机进行方法调用,每个方法从开始调用到执行完成的过程,就是栈帧从入栈到出栈的过程,每个栈帧中定义了局部变量表、操作栈、动态连接、方法返回地址
  • 堆区:存储着几乎所有的示例对象,垃圾回收的主要区域,内部分为新生代和老年代,新生代还按照比例分为 Eden 和 Survivor 区
  • 元数据区,也叫元空间,取代之前 JVM 内存区域永久代的概念,主要负责存放常量池、方法的元信息等

关于 JVM 的内存布局掌握两个重点,一个是线程私有的和共享的区域分别是什么,另一个是各个内存区域都会抛出什么异常。

  • 除了「程序计数器」,其余的内存区域都有可能抛出 OOM 异常。
  • 除了 OOM 异常,虚拟机栈和本地方法栈还可能抛出 StackOverflowError 错误,即栈溢出。
  • 堆区是 OOM 异常的主要发生地

此外,虚拟机栈、本地方法栈、程序计数器都是线程私有的,而堆区和元数据区则是现成共享的,他们之间靠得就是上述 ThreadLocal 进行交互。

Java 十四道由浅入深的笔面试题第五期的图片-高老四博客 第3张

图源《码出高效》,侵删

JVM 中的垃圾收集器(也叫垃圾收集器)

在说垃圾收集器之前你需要了解的是都有哪些垃圾回收算法。

  • 标记-清除:先标记「死对象」,然后直接清除
  • 复制算法:内存一分为二,只用一半,清除时将「活对象」复制到未使用的另一半,原来的内存直接清除。
  • 标记-整理:先标记,标记玩将「死对象」移至到一端,然后统一清除端边界的内存,解决「标记-清除」的空间碎片问题。
  • 分代收集:将堆区分代,新生代和老年代,按照对象的年龄代匹配不同的垃圾回收算法,新生代主要使用复制算法,老年代主要使用「标记-清理」和「标记-整理」算法。

JVM 中的垃圾回收器其实有很多,不过我们需要重点掌握 Serial、CMS 和 G1 垃圾收集器。

  • Serial 收集器:Young GC 使用复制算法,Full GC 使用「标记-整理」算法;单线程收集器;工作的时候「Stop The World」;简单而高效,没有线程交互的开销,专心做垃圾收集;
  • ParNew 收集器:Serial 收集器多线程版本、除了 Serial 收集器,只能与 CMS 收集器配合工作、默认收集线程数与 CPU 数量相同,有利于有效利用系统和线程资源。
  • Parallel Scavenge 收集器,「吞吐量优先」收集器:新生代收集器,使用复制算法,并行多线程收集器;目标是达到一个可控制的吞吐量:运行用户代码时间 / (CPU 用于运行用户代码时间 + 垃圾收集时间) ;
  • Serial Old 收集器:使用标记整理算法;Serial 收集器的老年代版本;
  • Parallel Old 收集器:使用标记整理算法;Parallel Scavenge 收集器的老年代版本
  • CMS(Concurrent Mark Sweep,并发整理清除) 收集器:使用标记清除算法;目标是获取最短回收停顿时间
  • G1 收集器:整体是标记-整理算法,结合复制算法;主要特点是可以预测停顿时间;

Serial 收集器影响最大的就是「Stop The World」,导致程序会有停止运行的时候,所以目前主要用于 C/S 模式中。

CMS 因为使用「标记-清除」回收算法,所以不可避免的会有内存空间碎片的问题,除此之外,CMS 对 CPU 的资源非常敏感,默认启动垃圾收集线程数:(CPU 数量 + 3)/ 4,意味着如果 CPU 的数量少于 4 个,那么占用资源的比重就回很高。CMS 还无法处理浮动垃圾从而会导致 Full GC。

CMS 的清理垃圾的四个步骤:

  1. 初始标记,Stop The World
  2. 并发标记
  3. 重新标记,Stop The World
  4. 并发清除

G1 回收期从 JDK7 开始正式作为商用,因为具备压缩功能,所以能够解决内存碎片的问题,并且手机垃圾对象时暂停时间更加可控,类似于 CMS,前三个步骤分别是初始标记、并发标记、重新标记,不过最后一步是筛选回收。G1 回收器将堆区分为若干大小相同的「region」区,优先回收垃圾最多的区域。

九、发生频繁的 FULL GC 该如何处理?CPU 使用率如何优化?

性能优化一直都是 Java 体系乃至整个互联网项目体系的要点,对于性能瓶颈以及相关调优的经验也代表了你是否经历过数据量比较大项目。从业务代码优化、CPU 相关、内存相关、磁盘 I/O 和网络 I/O 等方面均有对应的性能瓶颈、排查方式以及优化方法,多多少少都是我们需要实战并且积累经验的。

频繁的 FULL GC 该如何处理

首先要明确的是 Full GC 的可能都有哪些,通过原因找到解决问题的办法。频繁的 Full GC 或者老年代 GC,很有可能是存在内存泄漏,导致对象被长期持有,通过 dump 内存快照进行分析,一般能较快地定位问题;另外生代和老年代的比例不合适,导致对象频频被直接分配到老年代,也有可能会造成 Full GC,所以堆区 Eden 区和 Survivor 区的比例划分也要根据具体的内存环境做相应的调整,默认 8:1。

CPU 使用率如何优化

CPU 的使用率或者说CPU 负载是判断我们的业务系统资源是否健康的关键依据。

可以按照以下几方面归类排查问题并进行对应的问题决策。

CPU 利用率高 && 平均负载高

该种情形体现在 CPU 密集型的应用,正则操作、数学运算、序列化/反序列化、反射操作、死循环或者不合理的大量循环、基础/第三方组件缺陷、频繁的 GC 或者 CPU 本身的性能不足都有可能大量消耗 CPU 资源。

我们可以通过如下方式进行问题排查,找到症结,并对症结进行优化:

  • 通过 jstack 打印线程栈,一般可以定位到消耗 CPU 较多的线程堆栈
  • 通过 java Profiler 工具查看 CPU 火焰图
  • 使用「jstat -gcutil」命令持续输出当前应用的 GC 统计次数和时间
  • free 或者 top 命令查看当前机器内存的大小及占用情况

CPU 利用率低 && 平均负载高

该种情形体现在 I/O 密集型的应用,所以要着重检查耗时较长的网络请求或I/O 等待严重的情况

CPU 上下文切换次数变高

Linux 是一个多任务操作系统,它支持远大于 CPU 数量的任务同时运行。当然,这些任务实际上并不是真的在同时运行,而是因为系统在很短的时间内,将 CPU 轮流分配给它们,造成多任务同时运行的错觉。而在每个任务运行前,CPU 都需要知道任务从哪里加载、又从哪里开始运行,也就是说,需要系统事先帮它设置好CPU 寄存器和程序计数器。所以CPU 寄存器和程序计数器就是 CPU 上下文,因为它们都是 CPU 在运行任何任务前,必须的依赖环境。

那么 CPU 上下文切换就是先把前一个任务的 CPU 上下文(也就是 CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。这些保存下来的上下文,会存储在系统内核中,并在任务重新调度执行时再次加载进来。这样就能保证任务原来的状态不受影响,让任务看起来还是连续运行。

我们可以按照自愿上下文切换和非自愿上下文切换两种类型来分析CPU 上下文切换次数变高的情况,前者多半是线程状态发生转换导致,比如 sleep、join、wait 等方法或者使用了 Lock、synchronized 锁结构,意味着 CPU 存在 I/O、内存资源不足等情况。而后者则可能是线程由于被分配的时间片用完或由于执行优先级被调度器调度所致,也就是可能由于线程数过多。

十、都有哪些字节码?两个 Integer 的变量相等的判断是什么样的过程?

我们都知道 Java 能做到「一次编写,到处执行」是因为设计了 JVM,而这个字节码技术就是 JVM 与 CPU 交互的中间层实现,JVM 解析我们编写的 .java 源文件为字节码文件,通过一系列相应的字节码指令执行 java 文件中的变量、方法等业务操作。

字节码指令大体分类:

  • 加载或存储指令:ILOAD、ALOAD、ISTORE、ASTORE、ICONST、BIPUSH、SIPUSH、LDC
  • 运算指令(对两个操作栈上的值进行运算,并把结果写入操作栈顶):IADD、IMUL 等
  • 类型转换指令(显示转换两种不同的数值类型):I2L、D2F 等
  • 对象创建与访问指令:NEW、NEWARRAY、GETFIELD、PUTFIELD、GETSTATIC、INSTANCEOF、CHECKCAST
  • 操作栈管理指令(直接控制操作栈的指令):POP、DUP 等
  • 方法调用与返回指令:INVOKEVIRTUAL、INVOKESPECIAL、INVOKESTATIC、RETURN
  • 同步指令:ACC_SYNCHRONIZED、MONITORENTER、MONITOREXIT、LINENUMBER、LOCALVARIABLE

以上提到的具体命令基本上都是常用的,具体负责干嘛的都是你需要掌握的。

关于两个 Integer 变量相等的判断,有一个极负盛名的笔试题,在文末相关文章阅读之《阿里巴巴Java开发规约第二章-异常处理篇》中的第十条「防止NPE(NullPointerException),是程序员的基本修养,注意 NPE 产生的场景」我写过,可以先去那里看一下。

现在我们从字节码的层面看一下两个 Integer 变量相等的判断都发生了什么。首先我们使用反编译命令「javap -c」看一下都执行了什么字节码指令。

Java 十四道由浅入深的笔面试题第五期的图片-高老四博客 第4张

如上图其实我们就应该很清晰了,简单的概括一下。我们看到对于数字 88 使用的是「BIPUSH」,对于数字 200 使用的则是「SIPUSH」,这两个命令都是讲常量加载到操作栈顶,不同的是因为 Integer 缓存的原因,BIPUSH 负责加载 -128 ~ 127 之间得数,而除此之外,使用 SIPUSH 负责加载 -32768 ~ 32767 之间的数。这一个简短的代码程序几乎涉及到了老四上述提及到的字节码指令,还是需要着重掌握的。

十一、类加载器都有哪些?类加载器的加载机制是什么?

参见文末相关阅读文章之《浅析Java反射系列相关基础知识(上)之类的加载以及反射的基本应用》

十二、JVM 的常用优化方法都有哪些?

所谓的 JVM 优化其实主要指的是 GC 优化,也就是针对 JVM 的垃圾回收器进行相关优化,调整对应的 JVM 参数。所以针对使用的不同的垃圾回收器,相应的优化策略也是不同的,对应的 JVM 参数也不相同,另外对于 JVM 的优化也要根据系统需求而定,我们常说的高可用、低延迟、高吞吐量都属于性质不一样的系统环境,那么针对 JVM 调优也要对此进行考虑。

简单的说一下 JVM 优化的思路,首先要确定目标,到底要实现高可用还是低延迟等。其次就是针对你的需求进行合适的 GC 回收器选择,设置堆内存新生代、老年代的大小比例等,最后落实到对应 JVM 的参数设置上。

举个例子,比如线上发生频繁的 Minor GC(新生代垃圾回收)和 Major GC(老年代),这可能通常是因为新生代空间比较小,Eden 区很快被填满。由此增大新生代的空间来降低 Minor GC 的频率。我们知道 Eden 区的代下不会对单次 Minor GC 的时间产生严重影响,可以忽略不计,因此因为 MInor GC 频率降低,意味着新生代能够得到充分回收,这样老年代的增速就会降低,从而也帮助频繁的 Major GC 频率也降低了下来。

十三、Class.forname(java.lang.String) 与 String.class.getClassLoader().loadClass(“java.lang.String”) 有什么区别?

主要考察的就是类被加载后是否进行初始化,前者默认进行了初始化操作,后者没有。

我们都知道 Java 的类加载器是一个运行时核心基础设施模块,在启动之初进行类的 Load、Link、Init 三个操作。在类的加载过程中,Class.forname(java.lang.String) 对 String 对象进行了初始化操作,而 String.class.getClassLoader().loadClass(“java.lang.String”) 只进行了 Load 操作,没有进行初始化,也就是实例化操作。

所以一般情况下,这两个方法效果一样,都能装载Class。但如果程序需要 Class 被实例化,就必须用Class.forName(name)。

十四、final Integer 是线程安全的吗?

我们就 Integer 本身来讲它就是线程安全的,不可变对象就是天生的线程安全对象,因为你没有办法修改它。

Integer本身用 final int value存放数字,构造完成了就不可变了,只能读取不能修改肯定是安全的。

不过我们平时所说的如果引用不用 final 修饰的话,基本不会达到线程安全,其实这里所说的就是对于引用的复合操作,对于复合操作,即使引用本身是线程安全的,但也依然不能保证复合操作是线程安全的。另外我们也要考虑排除掉反射可以改变类的私有成员变量的值的情况,this 引用逃逸的现象。

所以排除这些特殊情况,final Integer 是线程安全的,注意,这严谨点来说叫做「线程相对安全」,我们 J.U.C 并发包中那些所谓的线程安全类,其实都是所谓的相对线程安全,因为一旦涉及到复合操作,还是需要额外的同步手段来保证线程安全。

final 关键字带来的可见性,只要一个不可变的对象被正确地构建出来(没有发生 this 引用逃逸、排除反射等情况),其外部的可见状态永远也不会改变。如果这个共享数据是一个对象,那就需要保证对象的行为不会对其状态产生任何影响就好,比如把对象中带有状态的变量都声明为 final,Integer 的源码中就是通过「final int value」来实现的。

在说说对象的状态,对象的状态主要是根据类的变量来划分,我们知道类变量有基本的四种形式:

  1. 公有变量(public)、私有变量(private)、保护变量(protect)
  2. 静态变量(static)
  3. 不可变变量(final)
  4. 外部变量、内部变量、局部变量

而根据各种类变量的有无可以划分如下对象的状态:

  • 无状态类 没有任何声明的成员变量,无状态类是线程安全的。
  • 有状态类 类中有声明的成员变量

根据有状态类,还可以继续划分为「私有状态类」和「共享状态类」,私有状态类指的是通过 ThreadLocal 等方法,使得状态被隔离在各个线程中,相互不干扰,达到线程安全。而我们经常讨论的按成安全和不安全基本都要针对的共享状态类。共享状态类可以继续细分为:

  • 不可变状态(final 常量)(多线程并发安全)
  • 可变状态

针对可变状态,无论是 static 关键字修饰的变量(和 final 配合使用保证线程安全),还是加锁操作、CAS 算法实现的非阻塞式设计,都是针对可变状态进行线程安全操作的方式。

相关文章阅读

更博不易,如果觉得文章对你有帮助并且有能力的老铁烦请捐赠盒烟钱,点我去赞助。或者扫描文章下面的微信/支付宝二维码打赏任意金额(点击「给你买杜蕾斯」),也可扫描小站放的支付宝领红包二维码,线下支付享受优惠的同时老四也可以获得对应赏金,老四这里抱拳谢谢诸位了。捐赠时请备注姓名或者昵称,因为您的署名会出现在赞赏列表页面,您的捐赠钱财也会被用于小站的服务器运维上面,再次抱拳感谢。

赞(29) 给你买杜蕾斯
本站原创文章受自媒体平台原创保护,未经允许不得转载高老四博客 » Java 十四道由浅入深的笔面试题第五期

开始你的表演 抢沙发

觉得文章有用就打赏一下老四,鼓励我更好的创作

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

支付宝扫一扫打赏

微信扫一扫打赏

登录

找回密码

注册