Java
JVM
参考:https://www.kuangstudy.com/bbs/1557549426359590914
- 请你谈谈你对jvm的理解?
- Java8虚拟机和之前的变化更新?
- 什么是OOM?什么是栈溢出StackOverFlowError?怎么分析?
- jvm的常见调优参数有哪些?
- 内存快照如何抓取?怎么分析Dump文件?
- 谈谈jvm中,类加载器你的认识?
- JVM的位置
-
JVM的体系结构
-
jvm结构图,从.java文件 -> .class文件 -> classloader -> 分配空间
-
jvm垃圾回收,在jvm中,线程私有的区域不会存在垃圾回收,因为都是栈和程序计数器,如果存在垃圾,那么程序就会死掉,无法正常运行。
-
jvm调优,调优肯定是垃圾回收的优化,那么就是线程共享的区域:堆、方法区
-
类加载器
- 类加载的过程:加载、初始化、实例化,这里的Car Class可以看作是一个模板
-
哪些类加载器:
- 引导类加载器(BootstrapClassloader):用C++编写,是JVM自带的类加载器;负责加载Java的核心类库。(该加载器无法直接获取)
- 扩展类加载器(ExtClassloader):负责加载/jre/lib/ext目录下的jar包。
- 应用程序类加载器(AppClassloader):负责加载java -classpath或-D java.class.path所指的目录下的类与jar包。(最常用的加载器)
-
双亲委派机制:(检查顺序从下至上,加载顺序从上至下,也就是从app->ext->boot检查,从boot->ext->app加载)
- 类加载器接收到一个加载请求时,他会委派给他的父加载器,实际上是去他父加载器的缓存中去查找是否有该类,如果有就加载返回,如果没有则继续委派给父类加载,
直到顶层
类加载器。 - 如果顶层类加载器也没有加载该类,则会依次向下查找子加载器的加载路径,如果有就加载返回,如果都没有,则会抛出异常。
- 类加载器接收到一个加载请求时,他会委派给他的父加载器,实际上是去他父加载器的缓存中去查找是否有该类,如果有就加载返回,如果没有则继续委派给父类加载,
双亲委派机制的类加载流程:
1、类加载器收到类加载请求
2、将这个请求向上委托给父类加载器,一直向上委托,直到启动类加载器
3、启动类加载器检查是否能执行该类,能加载就结束,使用当前加载器,不能加载就抛出异常通知子加载器进行加载。
如果类加载器返回null,那么java中找不到该类,可能在C、C++中,注意:java中调用系统本地的方法使用native字段,比如线程的线程的启动方法,java处理不了,就只能去用接口调用本地方法。
- 沙箱安全机制,了解
Native、方法区
-
Native:
- 凡是使用了native关键字的,说明Java的作用范围已经达不到了,它会去调用底层的C语言的库。
- 进入本地方法栈。
- 调用本地方法接口。JNI(Java Native Interface)的作用:扩展Java的使用,融合不同的语言为Java所用。(最初是为了融合C、C++语言)
- 所以Java在JVM内存区域专门开辟了一块标记区域Native Method Area Stack,用来登记native方法。
在最终执行(执行引擎执行)的时候,通过JNI来加载本地方法库中的方法。
- 凡是使用了native关键字的,说明Java的作用范围已经达不到了,它会去调用底层的C语言的库。
-
方法区:Method Area方法区(此区域属于共享区间,所有定义的方法的信息都保存在该区域)
方法区是被所有线程共享,所有字段、方法字节码、以及一些特殊方法(如构造函数,接口代码)也在此定义。
静态变量(static)、常量(final)、类信息(构造方法、接口定义)(class模板)、运行时的常量池存在方法区中,但是实例变量存在堆内存中,和方法区无关。
- 程序计数器:Program Counter Register
每个线程都有一个程序计数器,是线程私有的,就是一个指针,指向方法区中的方法字节码(用来存储指向像一条指令的地址,也即将要执行的指令代码),在执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略不计。
栈
-
栈的作用:栈内存,主管程序的运行,生命周期和线程同步;线程结束,栈内存也就释放了,对于栈来说,不存在垃圾回收问题。
-
栈存储的内容:8大基本类型、对象引用,实例的方法。
-
栈的运行原理:本地变量->局部变量表,注意父帧和子帧

- 栈+堆+方法区之间的交互关系:
堆
-
概念:Heap,一个JVM只有一个堆内存,堆内存的大小是可以调节的。
-
类加载器读取了类文件后,一般会把什么东西放到堆中?
类、方法、常量、变量、保存我们所有引用类型的真实对象。 -
堆内存中细分为三个区域:
- 新生代内存(Young Generation)(Eden)
- 老生代(Old Generation)
- 永久代(Permanent Generation) -> 元空间,可以理解为方法区的具体实现,所以常量池,静态变量,Class放在元空间中
-
堆内存结构
-
永久代和元空间:方法区是一种规范,不同的虚拟机厂商可以基于规范做出不同的实现,永久代和元空间就是出于不同jdk版本的实现。
方法区就像是一个接口,永久代与元空间分别是两个不同的实现类。
只不过永久代是这个接口最初的实现类,后来这个接口一直进行变更,直到最后彻底废弃这个实现类,由新实现类—元空间进行替代。-
jdk1.8之前:
-
jdk1.8:在堆内存中,逻辑上存在,物理上不存在(元空间使用的是本地内存)
-
-
常量池:
-
在jdk1.7之前,运行时常量池+字符串常量池是存放在方法区中,HotSpot VM对方法区的实现称为永久代。
-
在jdk1.7中,字符串常量池从方法区移到堆中,运行时常量池保留在方法区中。
-
jdk1.8之后,HotSpot移除永久代,使用元空间代替;此时字符串常量池保留在堆中,运行时常量池保留在方法区中,只是实现不一样了,JVM内存变成了直接内存。
-
GC垃圾回收
- 垃圾回收的区域
-
如何判断一个常量是
废弃常量
:假如在字符串常量池中存在字符串 “abc”,如果当前没有任何 String 对象引用该字符串常量的话,就说明常量 “abc” 就是废弃常量,如果这时发生内存回收的话而且有必要的话,“abc” 就会被系统清理出常量池了。 -
如何判断一个类是
无用的类
:无用的类需要同时满足下面3个条件- Java堆中不存在该类的任何实例
- 加载该类的ClassLoader已被收回
- 该类对应的
java.lang.Class
对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
-
GC之引用计数法:给对象中添加一个引用计数器:
- 每当有一个地方引用它,计数器就加 1;
- 当引用失效,计数器就减 1;
- 任何时候计数器为 0 的对象就是不可能再被使用的。
这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间循环引用的问题。
所谓对象之间的相互引用问题,如下面代码所示:除了对象
objA
和objB
相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为 0,于是引用计数算法无法通知 GC 回收器回收他们。
-
GC之标记清楚法:标记-清除(Mark-and-Sweep)算法分为“标记(Mark)”和“清除(Sweep)”阶段:首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。它作为最基础的收集算法,后续的算法都是对其不足进行改进,这个算法会带来两个明显的问题:
-
效率问题:标记和清除两个过程效率不高
-
空间问题:标记清楚后会产生大量不连续的空间碎片
-
-
GC之复制算法(
主要使用在新生代
):为了解决标记-清除算法的效率和内存碎片问题,复制(Copying)收集算法出现了。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。虽然改变了空间和效率的问题,但又产生了新问题:- 因为将内存分成两半,那么可用内存就变小了
- 不适合老年代,如果存货对象数量比较大,复制性能就会变差
现在的商业虚拟机都采用这种收集算法来回收新生代,但是并不是将新生代划分为大小相等的两块,而是分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 空间和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象一次性复制到另一块 Survivor 空间上,最后清理 Eden 和使用过的那一块 Survivor。
HotSpot 虚拟机的 Eden 和 Survivor 的大小比例默认为 8:1,保证了内存的利用率达到 90%。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 空间就不够用了,此时需要依赖于老年代进行分配担保,也就是借用老年代的空间存储放不下的对象。
- GC之标记整理算法:根据老年代的特点提出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。由于多了整理这一步,因此效率也不高,适合
老年代
这种垃圾回收频率不是很高的场景。
-
GC之分代回收算法:现在的商业虚拟机采用分代收集算法,它根据对象存活周期将内存划分为几块,不同块采用适当的收集算法。
一般将堆分为新生代和老年代。
- 新生代使用: 复制算法
- 老年代使用: 标记 - 清除 或者 标记 - 整理 算法
垃圾收集器
前面的收集算法是内存回收的方法论,而垃圾收集器就是这些方法的具体实现,我们能需要根据具体应用场景选择适合自己的垃圾收集器
- Serial收集器:串行
- ParNew收集器:Serial+并行
了解
为什么需要对垃圾回收进行分区?
这其实是和对象的存活概率挂钩的,存活时间长的,放在老年代,减少GC(垃圾回收)扫描的概率,新创建出来的对象实例,一般存放在伊甸园,而且不同的区域采用的垃圾回收的算法也是不一样的。
Young GC的触发机制
在新生代的Eden区域满了之后就会触发,采用复制算法来 回收新生代的垃圾
Old GC和Full GC的触发时机
1、发生Young GC之前进行检查,如果“老年代可用的连续内存空间” < “新生代历次Young GC后升入老年代的对象 总和的平均大小”,说明本次Young GC后可能升入老年代的对象大小,可能超过了老年代当前可用内存空间,此时必须先触发一次Old GC给老年代腾出更多的空间,然后再执行Young GC
这也就是 ygc之前,触发old gc
2、执行Young GC之后有一批对象需要放入老年代,此时老年代就是没有足够的内存空间存放这些对象了,此时必 须立即触发一次Old GC
这也就是ygc之后,触发old gc
3、老年代内存使用率超过了92%,也要直接触发Old GC,当然这个比例是可以通过参数调整的。
其实说白了,上述三个条件你概括成一句话,就是老年代空间也不够了,没法放入更多对象了,这个时候务必执行Old GC对老年代进行垃圾回收。
参考:https://zhuanlan.zhihu.com/p/420273533
代理
- 代理模式:**使用代理对象来代替对真实对象(real object)的访问,这样就可以在不修改原目标对象的前提下,提供额外的功能操作,扩展目标对象的功能。**比如:日志记录、参数校验、事务管理、权限校验
- 静态代理:在程序运行之前就给目标类编写了其代理类的方法,并对其编译,程序运行的时候能直接读取
具体代码可以看javaGuide:https://javaguide.cn/java/basis/proxy.html#_2-%E9%9D%99%E6%80%81%E4%BB%A3%E7%90%86
- 动态代理不需要针对每个目标类都单独创建一个代理类,并且也不需要我们必须实现接口,我们可以直接代理实现类( JDK、CGLIB 动态代理机制)。**从 JVM 角度来说,动态代理是在运行时动态生成类字节码,并加载到 JVM 中的。**说到动态代理,Spring AOP、RPC 框架应该是两个不得不提的,它们的实现都依赖了动态代理。
- JDK动态代理的实现流程:JDK动态代理只能代理实现了接口的类
- 定义一个接口及其实现类;
- 自定义
InvocationHandler
并重写invoke
方法,在invoke
方法中我们会调用原生方法(被代理类的方法)并自定义一些处理逻辑; - 通过
Proxy.newProxyInstance(ClassLoader loader,Class<?>[] interfaces,InvocationHandler h)
方法创建代理对象。
具体代码可以看javaGuide:https://javaguide.cn/java/basis/proxy.html#_2-%E9%9D%99%E6%80%81%E4%BB%A3%E7%90%86
-
静态代理、动态代理最大的区别在于动态代理不需要为每个类创建一个代理,使用统一的代理并通过代理工厂的方式实例化代理类,从而实现代理的动态化。
-
CGLIB动态代理机制:CGLIB是一个基于ASM的字节码生成库,它允许我们在运行时对字节码进行修改和动态生成。CGLIB 通过继承方式实现代理。很多知名的开源框架都使用到了CGLIB, 例如 Spring 中的 AOP 模块中:如果目标对象实现了接口,则默认采用 JDK 动态代理,否则采用 CGLIB 动态代理。
- 在使用过程中需要自定义
MethodInterceptor
并重写intercept
方法,intercept
用于拦截增强被代理类的方法。
public interface MethodInterceptor extends Callback{ // 拦截被代理类中的方法 //obj : 被代理的对象(需要增强的对象) //method : 被拦截的方法(需要增强的方法) //args : 方法入参 //proxy : 用于调用原始方法 public Object intercept(Object obj, java.lang.reflect.Method method, Object[] args,MethodProxy proxy) throws Throwable; }
- 通过
Enhancer
类来动态获取被代理类,当代理类调用方法的时候,实际调用的是MethodInterceptor
中的intercept
方法
- 在使用过程中需要自定义
-
JDK 动态代理和 CGLIB 动态代理对比
- JDK 动态代理只能代理实现了接口的类或者直接代理接口,而 CGLIB 可以代理未实现任何接口的类。 另外, CGLIB 动态代理是通过生成一个被代理类的子类来拦截被代理类的方法调用,因此不能代理声明为 final 类型的类和方法。
- 就二者的效率来说,大部分情况都是 JDK 动态代理更优秀,随着 JDK 版本的升级,这个优势更加明显。
基础:
-
多态:可以看成一个向上转形,Animal dog = new Dog(),将dog转形为Animal,不能调用只有Dog类中存在但是Animal类中不存在的方法;如果子类重写了父类中的方法,那么就执行子类的方法。
-
==和equals():
-
类没有重写
equals()
方法:通过equals()
比较该类的两个对象时,等价于通过“==”比较这两个对象,使用的默认是Object
类equals()
方法。 -
类重写了
equals()
方法:一般我们都重写equals()
方法来比较两个对象中的属性是否相等;若它们的属性相等,则返回 true(即,认为这两个对象相等).
-
-
异常捕获过程中:无论是否捕获或处理异常,
finally
块里的语句都会被执行。当在try
块或catch
块中遇到return
语句时,finally
语句块将在方法返回之前被执行。 -
静态变量可以被类的所有实例共享。无论一个类创建了多少个对象,它们都共享同一份静态变量。
-
重载就是同一个类中多个同名方法根据不同的传参来执行不同的逻辑处理
-
方法的重写要遵循“两同两小一大”:
- “两同”即方法名相同、形参列表相同;
- “两小”指的是子类方法返回值类型应比父类方法返回值类型更小或相等,子类方法声明抛出的异常类应比父类方法声明抛出的异常类更小或相等;
- “一大”指的是子类方法的访问权限应比父类方法的访问权限更大或相等。
-
Java中的引用类型就是除了基本数据类型之外的所有类型(如class类型)
-
基本类型和包装类型的区别?Java 中有 8 种基本数据类型,分别为:
byte
、short
、int
、long
、float
、double
、char、boolean
这八种基本类型都有对应的包装类分别为:Byte
、Short
、Integer
、Long
、Float
、Double
、Character
、Boolean
。-
成员变量包装类型不赋值就是
null
,而基本类型有默认值且不是null
。 -
包装类型可用于泛型,而基本类型不可以。
-
基本数据类型的局部变量存放在 Java 虚拟机栈中的局部变量表中,基本数据类型的成员变量(未被
static
修饰 )存放在 Java 虚拟机的堆中。包装类型属于对象类型,我们知道几乎所有对象实例都存在于堆中。 -
相比于对象类型, 基本数据类型占用的空间非常小
-
所有整型包装类对象之间值的比较,全部使用 equals 方法比较。
-
如何解决浮点数运算的精度丢失问题?
BigDecimal
可以实现对浮点数的运算,不会造成精度丢失。通常情况下,大部分需要浮点数精确运算结果的业务场景(比如涉及到钱的场景)都是通过BigDecimal
来做的。 -
**为什么重写 equals() 时必须重写 hashCode() 方法?**因为两个相等的对象的
hashCode
值必须是相等。也就是说如果equals
方法判断两个对象是相等的,那这两个对象的hashCode
值也要相等。如果重写equals()
时没有重写hashCode()
方法的话就可能会导致equals
方法判断是相等的两个对象,hashCode
值却不相等。-
equals
方法判断两个对象是相等的,那这两个对象的hashCode
值也要相等。 -
两个对象有相同的
hashCode
值,他们也不一定是相等的(哈希碰撞)。
-
Java引用类型:
- 强引用: 最普通的引用 Object o = new Object(),如果没有被引用那么GC就会被回收。
- 软引用: 垃圾回收器, 内存不够的时候回收 (缓存)。
- 弱引用: 垃圾回收器看见就会回收 (防止内存泄漏),ThreadLocal中key是弱引用, 其目的就是讲ThreadLocal对象的生命周期和和线程的生命周期解绑. 减少内存使用
- 虚引用: 垃圾回收器看见二话不说就回收,跟没有一样 (管理堆外内存) DirectByteBuffer -> 应用到NIO Netty
ThreadLocal
-
提供线程内的局部变星,不同的线程之间不会相互干扰,这种变量在线程的生命周期内起作用,减少同一个线程内多个函数或组件之间一些公共变量传递的复杂度.
-
特点:
特点 | 内容 |
---|---|
线程并发 | 在多线程并发场景下 |
传递数据 | 我们可以通过ThreadLocal在同一线程,不同组件中传递公共变量 (保存每个线程的数据,在需要的地方可以直接获取, 避免参数直接传递带来的代码耦合问题) |
线程隔离 | 每个线程的变量都是独立的, 不会互相影响.(核心) (各线程之间的数据相互隔离却又具备并发性,避免同步方式带来的性能损失) |
- ThreadLocal 和Synchronized的区别
synchronized | synchronized | ThreadLocal |
---|---|---|
原理 | 同步机制采用以时间换空间 的方式,只提供了一份变量, 让不同的线程排队访问 | ThreadLocal采用以空间换时间 的方式, 为每一个线程都提供了一份变量的副本, 从而实现同访问而相不干扰 |
侧重点 | 多个线程之间访问资源的同步 | 多线程中让每个线程之间的数据相互隔离 |
-
实现流程
-
每个THreadLocal线程内部都有一个Map(ThreadLocalMap)
-
Map里面存储的ThreadLocal对象(key)和线程变量副本(Value)也就是存储的值
-
Thread内部的Map是由ThreadLocal维护的, 有THreadLocal负责向map获取和设置线程变量值
-
对于不同的线程, 每次获取value(也就是副本值),别的线程并不能获取当前线程的副本值, 形成了副本的隔离,互不干扰.
-
-
强弱引用和内存泄漏
-
如果key是强引用:
-
如果key是弱引用:
-
内存泄漏的真实原因:
-
那么为什么 key 要用弱引用呢:事实上,在 ThreadLocalMap 中的set/getEntry 方法中,会对key为null(也即是ThreadLocal为null)进行判断,如果为null的话,那么将value置为null的.这就意味着使用完 ThreadLocal , CurrentThread 依然运行的前提下.就算忘记调用 remove 方法,弱引用比强引用可以多一层保障:弱引用的 ThreadLocal 会被回收.对应value在下一次 ThreadLocaIMap 调用 set/get/remove 中的任一方法的时候会被清除,从而避免内存泄漏.
-
此处参考:https://blog.csdn.net/bbscz007/article/details/105686382
常见面试题:
底层原理
内存泄漏的场景
为什么虚引用了还要remove
父线程的ThreadLocal子线程可以用吗
IheritableThreadLocal原理
线程池里的线程能用IheritableThreadLocal吗
为什么ThreadLocalMap中的key要用弱引用,Java有哪些引用
使用弱引用的话,如果key被垃圾回收了,value怎么办
那你如果调用get()方法之后,key被回收了,value会不会被回收(猜的)
threadlocal,定义在哪里的?threadlocal存在哪里?threadlocalMap放在哪里,key和value指向的是啥
ArrayList扩容机制
1.ArrayList的扩容是懒惰的,在没有添加元素之前,即使指定了容量,也不会真正创建数组;
2.使用add方法时:当存入数据时进行扩容,第一次扩容到长度为10的数组,替换掉长度为0的数组;第二次扩容为上一次的1.5倍
(实际上是[>>>1]),也就意味着第二次扩容大小为15,同时新数组依旧替换掉旧数组。
3使用addAll()方法时:
3.1当ArrayList中为空时,添加的元素的数量小于等于10时,第一次扩容为10,当添加的元素数量大于10时,扩容大小为元素数量。
例如:添加5个元素,第一次扩容为10;
添加12个元素,第一次扩容为12。
3.2当ArrayList中不为空时,规则类似于上一条,添加的元素数量与原本的元素数量如果大于当前容量且小于下次扩容的大小,则为1.5倍数组大小扩容,如果两者之和大于下次扩容大小,则扩容大小为两者元素和大小;
例如:当前ArrayList中已经有10个元素,再增加3个元素,则一共13个元素,小于扩容大小15,下一次扩容大小为15;
当前ArrayList中已经有10个元素,再增加6个元素,则一共16个元素,大于扩容大小15,下一次扩容大小为16。
CyclicBarrier 和 CountDownLatch 的区别
1、CountDownLatch 简单的说就是一个线程等待,直到他所等待的其他线程都执
行完成并且调用 countDown()方法发出通知后,当前线程才可以继续执行。
2、cyclicBarrier 是所有线程都进行等待,直到所有线程都准备好进入 await()方
法之后,所有线程同时开始执行!
3、CountDownLatch 的计数器只能使用一次。而 CyclicBarrier 的计数器可以使
用 reset() 方法重置。所以 CyclicBarrier 能处理更为复杂的业务场景,比如如果
计算发生错误,可以重置计数器,并让线程们重新执行一次。
4、CyclicBarrier 还提供其他有用的方法,比如 getNumberWaiting 方法可以获
得 CyclicBarrier 阻塞的线程数量。isBroken 方法用来知道阻塞的线程是否被中断。
如果被中断返回 true,否则返回 false。
说一下Select、Poll、Epoll的区别
https://zhuanlan.zhihu.com/p/367591714
OOM如何分析
https://blog.csdn.net/CSDN_WYL2016/article/details/107749678
算法
sort函数
sort()方法会接受一个比较函数compare(a, b),该函数要比较两个值,然后返回一个用于说明这两个值的相对顺序的数字。最后,永远要记住一句话,凡是返回1或者大于0的正数的时候就要交换位置。
(1)Arrays.sort:针对数组
递增排序:Arrays.sort(arr);
递减排序:Arrays.sort(arr, (a, b) -> b - a);
或使用:Arrays.sort(scores,Collections.reverseOrder()); 需要注意的是 arr不能使用基本类型(int,double, char),如果是int型需要改成Integer,float要改成Float。这里也要注意object[] arr是需要自己new每个对象的,因为object的默认值是null;而int[] arr等基本类型的默认值不是null,所以可以直接使用。
按[0]维升序排,如果[0]相等,则按[1]维升序排:
int[][] array = {{5, 7}, {5, 2}, {2, 5}, {5, 8}, {7, 1}, {4, 3}, {2, 3}};
Arrays.sort(array, (e1, e2) -> e1[0] == e2[0] ? e1[1] - e2[1] : e1[0] - e2[0]);
(2)Collections.sort:针对集合类(容器)
递增排序:Collections.sort(list);
按getName()结果递增排序:Collections.sort(list, (e1, e2) -> e1.getName()-e2.getName());
按x升序排,如果x相等,则按y升序排:
Collections.sort(list,(e1,e2)->e1.x==e2.x ? e1.y-e2.y: e1.x-e2.x);
(3)类实现Comparable接口,重写compareTo() 比直接在sort()里面用lamda函数时间复杂度低
class Node implements Comparable{
int x;
int y;
public Node(int x,int y) {
this.x = x;
this.y = y;
}
@Override
public int compareTo(Object o) {
Node node = (Node) o;
//按x升序排,如果x相等,则按y升序排
return this.x==node.x?this.y-node.y:this.x-node.x;
}
}
String#equals() 和 Object#equals() 有何区别?
String
中的 equals
方法是被重写过的,比较的是 String 字符串的值是否相等。 Object
的 equals
方法是比较的对象的内存地址。