Java 重要知识点记录
TOC
- TOC
- JVM 相关
- Java 锁相关
- Java 数据结构原理
- Java 多线程相关
- Spring 相关
- Maven 相关
- 其它
- 二进制数中的右移计算问题
- List.toArray(T[] a) 返回的是什么?
- Arrays.asList() 静态内部类大坑
- Java 序列化为什么要实现 Serializable 接口?
- 为什么 Lambda 表达式(匿名类) 不能访问非 final 的局部变量呢?
- JDK JRE JVM 关系
- 为什么说 Java 语言“编译与解释并存”?
- 重载(overload)和重写(override)的区别?
- 抽象类(abstract class)和接口(interface)有什么区别?
- try 和 finally 中的 return
- String 和 StringBuilder、StringBuffer 的区别?
- 关于 Integer
- BIO、NIO、AIO
JVM 相关
内存结构
程序计数器是一块较小的内存空间,是当前线程正在执行的那条字节码指令的地址,线程私有。
Java 虚拟机栈会为每一个即将运行的 Java 方法创建一块叫做“栈帧”的区域:
- 局部变量表:用于存储方法的参数和局部变量,包括基本数据类型和对象引用。它的作用是提供一个临时的存储区域,用于在方法执行过程中存储和访问方法的参数和局部变量。大小在编译期即确定。
- 操作数栈:用于存储方法执行过程中的临时数据,包括基本数据类型和对象引用。它的作用是提供一个临时的存储区域,用于在方法执行过程中进行计算和操作。
- 动态链接:主要就是指向运行时常量池的方法引用,方便调用其他方法。
本地方法栈是为 JVM 运行 Native 方法准备的空间,它与 Java 虚拟机栈实现的功能类似,只不过本地方法栈是描述本地方法运行过程的内存模型。
堆是用来存放对象的内存空间,几乎所有的对象都存储在堆中。堆是线程共享的,在 jvm 启动时创建,分为新生代和老年代,默认为 1:2 的关系。
新生代具体分为 Eden 区和 Survivor 区,对于 Survivor s0,s1 区,复制之后有交换,谁空谁是 To。HotSpot 中,Eden 空间和另外两个 Survivor 空间缺省所占的比例是 8:1:1。
在 Eden 区中有一个内存分配区叫 TLAB, 全称是 Thread Local Allocation Buffer,即线程本地分配缓存区,这是一个线程专用的内存分配区域,线程私有,默认开启的(当然也不是绝对的,也要看哪种类型的虚拟机)。TLAB 可避免多线程冲突,在给对象分配内存时,每个线程使用自己的 TLAB,这样可以使得线程同步,提高了对象分配的效率。如果失败了就会使用加锁的机制来保持操作的原子性。
方法区是 Java 虚拟机规范中定义方法区是堆的一个逻辑部分,它有一个别名叫作非堆(Non-Heap)。方法区有多种实现方式,JDK8 以前由 JVM 内存中的永久代实现,JDK8 以后由本地内存中的元空间实现。方法区是线程共享的,存放内容如下:
- 已经被虚拟机加载的类信息
- 即时编译器编译后的代码
- 运行时常量池
- 字符串常量池
- 静态变量
JDK6 以前以上信息都存在永久代中,JDK7 开始将字符串常量池和静态变量放到堆中,JDK8 再将其余信息存到本地内存的元空间中。
PS: JDK7 将字符串常量池移动到堆中的原因主要是永久代的 GC 回收效率太低,只有在 FullGC 的时候才会被执行 GC。Java 程序中通常会有大量的被创建的字符串等待回收,将字符串常量池放到堆中,能够更高效及时地回收字符串内存。而 JDK8 取消永久代而改用本地内存是为了让实际系统的内存大小进行控制,从而能够加载更多类信息。
重要文章:
对象创建流程
- 首先会在 Eden 区分配空间给这个对象;
- 当 Eden 区满将触发 Minor GC,Eden 区存活的对象进入 Survivor0,Survivor0 存活的对象移至 Survivor1;
- 当对象在 Survivor 区移动了 15 次(默认)还存活,则进入老年代;
- 当老年代满将触发 Major GC。
除此之外,还有 Full GC,它是针对整个新生代、老生代、元空间的全局范围的 GC。触发条件如下:
- 当创建一个大对象,Eden 区域当中放不下这个大对象,会直接保存在老年代当中,如果老年代空间也不足,就会触发 Full GC;
- 当永久代(jdk<8)当中没有足够的空间,就出触发一次 Full GC;
- 通过 Minor GC 后进入老年代的平均大小大于老年代的可用内存时,也会触发 Full GC;
- 调用
System.gc()
时,系统建议执行 Full GC,但是不必然执行; - 在新生代回收内存时,由 Eden 区和 Survivor From 区把存活的对象向 Survivor To 区复制时,对象大小大于 Survivor To 空间的可用内存,则把该对象转存到老年代(这个过程称为分配担保),且老年代的可用内存小于该对象大小。即老年代无法存放下新年代过度到老年代的对象的时候,便会触发 Full GC。
对象判活方法
对象的回收包括堆和方法区中对象的回收,判活方式大同小异。
JVM 现在采用可达性分析法判断堆中对象是否存活,引用计数法性能较低且有循环引用问题可能导致对象无法被正常回收,已被淘汰。
引用计数法是在对象头维护着一个 counter 计数器,对象被引用一次则计数器 +1;若引用失效则计数器 -1。当计数器为 0 时,就认为该对象无效了。
可达性分析法是通过 GC Roots 判断对象是否存活,所有和 GC Roots 直接或间接关联的对象都是有效对象,和 GC Roots 没有关联的对象就是无效对象。GC Roots 主要包括栈中引用的对象和方法区中常量和类静态属性引用的对象。GC Roots 并不包括堆中对象所引用的对象,这样就不会有循环引用的问题。
堆中对象只要通过可达性分析判断死亡,则会被回收。但在用户程序干预下也有可能不会回收,如果程序重写了 finalize 方法,而 finalize 方法在对象被垃圾收集器回收之前调用,对象回收前会被放入一个 F-Queue 队列中,虚拟机会以较低的优先级执行这些 finalize 方法,如果 finalize 方法中将 this 赋给了某一个引用,那么该对象就重生了,不会被回收。如果没有,那么就会被垃圾收集器清除,即使如果 finalize 方法出现耗时操作,虚拟机也会直接停止执行该方法,将对象清除。
对于方法区,其存放生命周期较长的类信息、常量、静态变量,每次垃圾收集只有少量的垃圾被清除。方法区中主要清除废弃常量和无用的类。只要常量池中的常量不被任何变量或对象引用,那么这些常量就会被清除掉。判定无用的类条件较为苛刻:
- 可达性分析:该类的所有对象都已经被清除;
- 类加载器的可达性:加载该类的 ClassLoader 已经被回收;
- 类的使用情况:该类的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
注:GC Roots 包含:
- 虚拟机栈中引用的对象;
- 方法区中类静态变量和常量引用的对象;
- 本地方法栈中 JNI 引用的对象。
对象引用方式
既然知道了如何判活对象,也就是判断对象是否有被引用,那么又如何确定对象的引用状态呢?
- 强引用:创建一个对象并把这个对象赋给一个引用变量,普通 new 出来对象的变量引用都是强引用;
- 软引用:如果一个对象具有软引用,内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。可通过 SoftReference 指定某个对象引用为软引用;
- 弱引用:弱引用的对象拥有比软引用更短暂的生命周期,当 JVM 进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象,可通过 WeekReference 指定某个对象引用为弱引用;
- 虚引用:虚引用并不会决定对象的生命周期,如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收,可通过 PhantomReference 指定某个对象引用为虚引用。虚引用必须和引用队列联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。
对于软引用、弱引用和虚引用,如果我们希望当一个对象被垃圾回收器回收时能得到通知,进行额外的处理,这时候就需要使用到引用队列 ReferenceQueue 了。可以在创建引用后将该引用和一个引用队列相关联,在一个对象被垃圾回收器扫描到将要进行回收时 reference 对象会被放入其关联的引用队列中。我们可从引用队列中获取到相应的对象信息,同时进行额外的处理。比如反向操作,数据清理,资源释放等。
对象回收算法
- 标记清除算法:遍历 GC Roots 标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。效率低,产生内存碎片;
- 复制算法:用于新生代。将可用内存按容量划分为大小相等的两块,每次只使用其中一块,当这一块内存用完了,就将还存活的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。效率高,不产生内存碎片,但浪费空间,而 Eden 和 Survivor 8:1:1 的分配缓解了空间的浪费;
- 标记整理算法:用于老年代。标记过程仍然与标记清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。效率适中,不产生内存碎片,不浪费空间,老年代的对象一般寿命比较长,因此每次垃圾回收会有大量对象存活,如果采用复制算法,每次需要复制大量存活的对象,效率变低。
- 分代收集算法:根据对象存活周期的不同,将内存划分为几块。一般是把 Java 堆分为新生代和老年代,针对各个年代的特点采用最适当的收集算法。新生代一般使用复制算法,老年代一般使用标记清除算法或标记整理算法。
对象分配优化:逃逸分析
逃逸分析是一种确定指针动态范围的静态分析,它可以分析在程序的哪些地方可以访问到指针,也就是分析一个对象的动态作用域是否会逃逸出方法范围、或者线程范围。
逃逸分析分为方法逃逸和线程逃逸两种:
- 方法逃逸:当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其它方法中。
- 线程逃逸:这个对象甚至可能被其它线程访问到,例如赋值给类变量或可以在其它线程中访问的实例变量。
经过逃逸分析,如果对象没有逃逸,那么可以得到代码优化的方式,主要有以下三种:
- 栈上分配:对象不分配在堆上,而是分配在栈内存上。可快速地在栈帧上创建和销毁对象,有效减少 JVM GC 压力。
- 标量替换:化整为零,将对象成员变量分解若干个被这个方法使用的成员变量,替换之后的成员变量可在栈帧或寄存器上分配空间,不会因为没有一大块连续空间导致对象内存不够分配。
- 消除同步锁:若一个对象只能从一个线程被访问到,则访问这个对象时可以不加同步锁。如果程序中使用了 synchronized 内置锁,则会将 synchronized 内置锁消除。
注意:消除同步锁针对的是 synchronized 锁,而对于非内置锁,比如 Lock 显示锁、CAS 乐观锁等等,则 JVM 并不能消除。
重要垃圾回收器
Serial
串行垃圾回收器,一个单线程的收集器,在进行垃圾收集时候,必须暂停其他所有的工作线程直到它收集结束。
特点:CPU 利用率最高,停顿时间即用户等待时间比较长。
通过 JVM 参数 -XX:+UseSerialGC
可以使用串行垃圾回收器。
Parallel
并发标记扫描垃圾回收器,采用多线程来通过扫描并压缩堆。
特点:停顿时间短,回收效率高,对吞吐量要求高。
通过 JVM 参数 XX:+USeParNewGC
打开并发标记扫描垃圾回收器,java8 中大部分使用的虚拟机 Hotspot JVM 虚拟机默认使用的垃圾回收器就算 Parallel。
CMS
CMS(Concurrent Mark Sweep, 同步标记清理)收集器。
CMS:以获取最短回收停顿时间为目标的收集器,基于并发“标记清理”实现,回收过程:
- 初始标记:独占 CPU,仅标记 GCroots 能直接关联的对象
- 并发标记:可以和用户线程并行执行,标记所有可达对象
- 重新标记:独占 CPU(STW),对并发标记阶段用户线程运行产生的垃圾对象进行标记修正
- 并发清理:可以和用户线程并行执行,清理垃圾
- 优点:并发收集、低停顿。
- 缺点:
- 对 CPU 资源敏感:在并发阶段虽然不会导致用户线程停顿,但是会因为占用了一部分线程使应用程序变慢
- 无法处理浮动垃圾:在最后一步并发清理过程中,用户线程执行也会产生垃圾,但是这部分垃圾是在标记之后,所以只有等到下一次 gc 的时候清理掉
- 它使用的回收算法 “标记-清除” 算法导致收集结束时会有大量空间碎片产生
G1
G1: Garbage-First
- 并行于并发:G1 能充分利用多核环境下的硬件优势。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并行的方式让 java 程序继续执行
- 分代收集:分代概念在 G1 中依然得以保留,G1 不需要其它收集器配合就能独立管理整个 GC 堆
- 空间整合:G1 使用了独立区域(Region)概念,G1 从整体来看是基于“标记-整理”算法实现垃圾回收,从局部(两个 Region)上来看是基于“复制”算法实现垃圾回收
- 可预测的停顿:G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用这明确指定一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒
关于 Region 分区思想:
对于 CMS 及之前的回收器来说,其 JVM 内存空间按照分代的思路划分成物理连续的一大片区域,如下图所示:
但在 G1 回收器中,虽然也采用了分代的思路,但其并没有为其分配一块连续的内存,而是将整块内存化整为零拆分成一个个 Region,如下图所示:
正如上图所示,G1 回收器不再为年轻代和老年代划分大块的内存,而是划分成了一个个的 Region,每个 Region 被标记成年轻代或者老年代。在 G1 中,还多了一个 Humongous 区域,其是为了优化大对象的分配而诞生的。
PS: 为了避免全堆扫描,G1 使用了 Remembered Set 来管理相关的对象引用信息。当进行内存回收时,在 GC 根节点的枚举范围中加入 Remembered Set 即可保证不进行全堆扫描也不会有遗漏了。
G1 回收过程可划分为以下几个步骤:
- 初始标记:标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS(Next Top Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的 Region 中创建新对象,这个阶段需要停顿线程,但耗时很短
- 并发标记:从 GC Roots 开始对堆中对象进行可达性分析,找出存活对象,这一阶段耗时较长但能与用户线程并发运行
- 最终标记:把 Remembered Set Logs 的数据合并到 Remembered Set 中,这阶段需要停顿线程,但可并行执行
- 筛选回收:首先对各个 Region 的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划,这一过程同样是需要停顿线程的(Sun 公司透露这个阶段其实也可以做到并发,但考虑到停顿线程将大幅度提高收集效率,所以选择停顿)
线上遇到 JVM OOM 的排查步骤
为什么会 OOM?当 JVM 因为没有足够的内存来为对象分配空间,并且垃圾回收器也已经没有空间可回收时,就会抛出这个错误,一般由这些问题引起:
- 分配过少:JVM 初始化内存小,业务使用了大量内存; 或者不同 JVM 区域分配内存不合理
- 代码漏洞:某一个对象被频繁申请,不用了之后却没有被释放,导致内存耗尽
- 内存泄漏:申请使用完的内存没有释放,导致虚拟机不能再次使用该内存,此时这段内存就泄露了。因为申请者不用了,而又不能被虚拟机分配给别人用
- 内存溢出:申请的内存超出了 JVM 能提供的内存大小,此时称之为溢出
可能的处理方式:
- 通过 ps 或 jps 命令找出系统中的 java 程序。
- 通过 top 命令 观察哪个 java 程序内存占用率一直偏高。
- 通过 jstat 命令
jstat -gcutil pid interval(ms) times
,可以根据某个 java 进程 id,按照指定间隔观测 Java 堆状况(各个区的容量、使用容量、gc 时间等信息),也可以通过 arthas 完成,直接选择 java 程序即可。 - 通过 jmap 命令
jmap -histo pid
可以参考堆中每个类的实例数量和内存占用,通过命令jmap -dump:format=b,file=xxx [pid]
可将堆内存快照 dump 出来进行进一步分析。 - 通过 arthas 命令
sc -d
可以搜索出所有已经加载到 JVM 中的 Class 信息,根据其类加载器,结合 arthas 的 classloader 命令可以分析是否为同一对象。 - 通过 MAT 工具来对 dump 出来的堆快照文件进行分析,看看是哪个对象太多导致内存溢出了。
- 通过 arthas 命令
jad
可以反编译指定已加载类的源码,查看和分析线上代码是否导致 OOM。 - 通过 arthas 命令
trace
可以主动搜索 class-pattern/method-pattern 对应的方法调用路径,渲染和统计整个调用链路上的所有性能开销和追踪调用链路,方便分析 OOM 问题。 - 通过 arthas 命令
watch
能方便的观察到指定函数的调用情况。能观察到的范围为:返回值、抛出异常、入参,通过编写 OGNL 表达式进行对应变量的查看。
JVM 调优记录
遇到以下情况,就需要考虑进行JVM调优了:
- Heap 内存(老年代)持续上涨达到设置的最大内存值;
- Full GC 次数频繁;
- GC 停顿时间过长(超过1秒);
- 应用出现 OutOfMemory 等内存异常;
- 应用中有使用本地缓存且占用大量内存空间;
- 系统吞吐量与响应性能不高或下降。
类加载流程
类加载过程:加载->连接->初始化。
连接过程又可分为三步:验证->准备->解析。
JVM 中内置了三个重要的 ClassLoader:
- BootstrapClassLoader(启动类加载器):最顶层的加载类,主要用来加载 JDK 内部的核心类库以及被
-Xbootclasspath
参数指定的路径下的所有类,通常表示为 NULL。 - ExtensionClassLoader(扩展类加载器):主要负责加载
%JRE_HOME%/lib/ext
目录下的 jar 包和类以及被java.ext.dirs
系统变量所指定的路径下的所有类。 - AppClassLoader(应用程序类加载器):面向我们用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。
双亲委派模型:
依赖双亲委派模型,子类加载器可以使用父类加载器加载的类,而父类加载器无法使用子类加载器加载的类。
双亲委派模型保证了 Java 程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了 Java 的核心 API 不被篡改。
Java 锁相关
锁的总结
重要文章
Java 对象头原理
锁状态信息,锁升级关键:
CAS 和 自旋锁
Compare And Swap,比较与交换,是一种无锁算法。在不使用锁(没有线程被阻塞)的情况下实现多线程之间的变量同步。java.util.concurrent 包中的原子类就是通过 CAS 来实现了乐观锁。
CAS 算法涉及到三个操作数:
- 需要读写的内存值 V。
- 进行比较的值 A。
- 要写入的新值 B。
当且仅当 V 的值等于 A 时,CAS 通过原子方式用新值 B 来更新 V 的值(“比较+更新”整体是一个原子操作),否则不会执行任何操作。一般情况下,“更新”是一个不断重试的操作。
CAS 有三大问题:
- ABA 问题。解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。
- 循环时间长开销大。CAS 操作如果长时间不成功,会导致其一直自旋,给 CPU 带来非常大的开销,可以通过设定最大自旋时间来解决。
- 只能保证一个共享变量的原子操作。对多个共享变量操作时,CAS 是无法保证操作的原子性的。
自旋锁原理即是 CAS,自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。
所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是 10 次,可以使用 -XX:PreBlockSpin 来更改)没有成功获得锁,就应当挂起线程。
也可以采用自适应自旋锁,自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
Syncronized 同步原理
分为 synchronized 代码块和 synchronized 方法讲述。
synchronized 代码块本质上是通过 Monitor(监视器) 这个锁计数器来实现的。
每一个对象在同一时间只与一个 monitor 相关联,而一个 monitor 在同一时间只能被一个线程获得。对象头的 mark word 记录了指向 monitor 的指针。执行 monitorenter 指令就是线程试图去获取 monitor 的所有权,抢到了就是成功获取锁了;执行 monitorexit 指令则是释放了 monitor 的所有权。
一个对象在尝试获得与这个对象相关联的 monitor 锁的所有权的时候,monitorenter 指令会发生如下 3 中情况之一:
- monitor 计数器为 0,意味着目前还没有被获得,那这个线程就会立刻获得然后把锁计数器 +1,一旦 +1,别的线程再想获取,就需要等待
- 如果这个 monitor 已经拿到了这个锁的所有权,又重入了这把锁,那锁计数器就会累加,变成 2,并且随着重入的次数,会一直累加
- 这把锁已经被别的线程获取了,等待锁释放
下图表现了对象,对象监视器,同步队列以及执行线程状态之间的关系:
synchronized 方法的同步并没有通过指令 monitorenter 和 monitorexit 来完成(理论上其实也可以通过这两条指令来实现),不过相对于普通方法,其常量池中多了 ACC_SYNCHRONIZED 标示符。JVM 就是根据该标示符来实现方法的同步的:
当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取 monitor,获取成功之后才能执行方法体,方法执行完后再释放 monitor。在方法执行期间,其他任何线程都无法再获得同一个 monitor 对象。
两种同步方式本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。两个指令的执行是 JVM 通过调用操作系统的互斥原语 mutex 来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。
Synchronized 中的锁升级
- 无锁 - 初始状态
一般情况下,新 new 出来的一个对象,暂时就是无锁状态。因为偏向锁默认是有延迟的,在启动 JVM 的前 4s 中不存在偏向锁,但是如果关闭了偏向锁延迟的设置,new 出来的对象,就会添加一个匿名偏向锁。也就是说这个对象想找一个线程去增加偏向锁,但是没有找到,称之为匿名偏向。存储的线程 ID 为一堆 0000,也没有任何地址信息。
- 偏向锁 - 单线程可重入
共享资源首次被访问时,JVM 会对该共享资源对象做一些设置,将对象头中是否偏向锁标志根据 JVM 参数位置为 0/1,如果关闭了偏向锁则为 0 进入轻量级锁,否则为 1,然后将对象头中的线程 ID 设置为当前线程 ID(注意:这里是操作系统的线程 ID),后续当前线程再次访问这个共享资源时,会根据偏向锁标识跟线程 ID 进行比对是否相同,比对成功则直接获取到锁,进入临界区域(就是被锁保护,线程间只能串行访问的代码),这也是 synchronized 锁的可重入功能。
- 轻量级锁 - 多线程有限 CAS 竞争
当多个线程同时申请共享资源锁的访问时,这就产生了竞争,JVM 会先尝试使用轻量级锁,以 CAS 方式来获取锁,成功则获取到锁,状态为轻量级锁,失败(达到一定的自旋次数还未成功)则锁升级到重量级锁。
- 重量级锁 - 阻塞
若当前一个线程持有锁,且当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。
Synchronized 锁了谁
对于 Synchronized 方法:
1、静态方法上的锁
静态方法是属于“类”,不属于某个实例,是所有对象实例所共享的方法。也就是说如果在静态方法上加入synchronized,那么它获取的就是这个类的锁,锁住的就是这个类。
2、普通方法上的锁
实例方法并不是类所独有的,每个对象实例独立拥有它,它并不被对象实例所共享。在实例方法上加入synchronized,那么它获取的就是这个类的锁,锁住的就是这个对象实例。
对于 Synchronized 代码块:
1、synchronized(this){...}
this 关键字所代表的意思是该对象实例,这种用法 synchronized 锁住的是对象实例。
2、synchronized(Demo.class){...}
锁的是该类。
3、synchronized(obj){...}
synchronized 同步代码块对对象内部的实例加锁。
假设 demo1 与 demo2 方法不相关,此时两个线程对同一个对象实例分别调用 demo1 与 demo2,均能获取各自的锁。
代码如下:
1public class Demo {
2 private Object lock1 = new Object();
3 private Object lock2 = new Object();
4
5 public void demo1() {
6 synchronized (lock1) {
7 while (true) { //死循环目的是为了让线程一直持有该锁
8 System.out.println(Thread.currentThread());
9 }
10 }
11 }
12
13 public void demo2() {
14 synchronized (lock2) {
15 while (true) {
16 System.out.println(Thread.currentThread());
17 }
18 }
19 }
20}
demo1 方法中的同步代码块锁住的是 lock1 对象实例,demo2 方法中的同步代码块锁住的是 lock2 对象实例。
如果线程 1 执行 demo1,线程 2 执行 demo2,由于两个方法抢占的是不同的对象实例锁,也就是说两个线程均能获取到锁执行各自的方法。
公平锁 VS 非公平锁
公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。
非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。
Synchronized 和 ReentrantLock 默认都使用非公平锁。公平锁就是通过同步队列来实现多个线程按照申请锁的顺序来获取锁,从而实现公平的特性。非公平锁加锁时不考虑排队等待问题,直接尝试获取锁,所以存在后申请却先获得锁的情况。
可重入锁 VS 非可重入锁
可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者 class),不会因为之前已经获取过还没释放而阻塞。ReentrantLock 和 synchronized 都是可重入锁。
ReentrantLock 和 NonReentrantLock 都继承父类 AQS,其父类 AQS 中维护了一个同步状态 status 来计数重入次数,status 初始值为 0。
当线程尝试获取锁时,可重入锁先尝试获取并更新 status 值,如果 status == 0 表示没有其他线程在执行同步代码,则把 status 置为 1,当前线程开始执行。如果 status != 0,则判断当前线程是否是获取到这个锁的线程,如果是的话执行 status+1,且当前线程可以再次获取锁。而非可重入锁是直接去获取并尝试更新当前 status 的值,如果 status != 0 的话会导致其获取锁失败,当前线程阻塞。
释放锁时,可重入锁同样先获取当前 status 的值,在当前线程是持有锁的线程的前提下。如果 status - 1 == 0,则表示当前线程所有重复获取锁的操作都已经执行完毕,然后该线程才会真正释放锁。而非可重入锁则是在确定当前线程是持有锁的线程之后,直接将status 置为 0,将锁释放。
独享锁 VS 共享锁
独享锁和共享锁同样是一种概念,对应于 ReentrantLock 和 ReentrantReadWriteLock。
独享锁也叫排他锁,是指该锁一次只能被一个线程所持有。如果线程 T 对数据 A 加上排他锁后,则其他线程不能再对 A 加任何类型的锁。获得排他锁的线程即能读数据又能修改数据。JDK 中的 synchronized 和 JUC 中 Lock 的实现类就是排他锁。
共享锁是指该锁可被多个线程所持有。如果线程 T 对数据 A 加上共享锁后,则其他线程只能对 A 再加共享锁,不能加排他锁。获得共享锁的线程只能读数据,不能修改数据。
独享锁与共享锁也是通过 AQS 来实现的,通过实现不同的方法,来实现独享或者共享。
Volatile
- 保证共享变量的可见性:线程修改 volatile 修饰的变量时会立即写入主存,并把主存当前块在其它 cache 中缓存都置为无效。其他线程所在 cache 探听总线,当发现自己块对应的主存已修改时,就把本 cache 中对应块置为无效状态。当处理器要对这个数据读写时,发现块已失效,会重新从主存中调入块到 cache 后再读取。
- 防止指令重排序:volatile 防止指令重排序是通过内存屏障来实现的。编译器在生成字节码文件时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
对于指令重排序,最好的例子是双重检测加锁的单例模式,正常情况下新建一个对象的过程是这样的:
- 分配内存空间
- 初始化对象
- 将内存空间的地址赋值给引用
但由于编译器可以对指令进行重排序,所以上面的过程也可能变成如下过程:
- 分配内存空间
- 将内存空间的地址赋值给引用
- 初始化对象
如果是这个过程,那么多线程环境下就将一个未初始化对象的引用暴露出来,从而导致不可预料的结果。因此,为了防止这个过程的重排序,需要将这个变量修改为 volatile 类型。
AQS 原理
AQS(AbstractQueuedSynchronizer),是 Java 中用于实现同步器的一个框架,它提供了一种基础的工具来构建锁和其他同步组件,如信号量、读写锁等。
核心概念
- 队列:AQS 使用一个 FIFO(先进先出)队列来管理线程的等待状态。当线程尝试获取锁失败时,它会被加入到这个队列中。
- 状态:AQS 维护一个整型状态变量(state),用于表示当前的锁状态。状态的变化通常是通过原子操作来完成的,以确保线程安全。
工作机制
- 获取锁:线程尝试通过 tryAcquire 方法获取锁。如果获取成功,它会更新状态并返回;如果失败,它会被加入到等待队列中。tryAcquire 是实现公平或非公平锁的关键。
- 释放锁:线程通过 tryRelease 方法释放锁,并更新状态。其他在等待队列中的线程会被唤醒,尝试获取锁。
- 条件变量:AQS 还支持条件变量,通过 ConditionObject 类,可以实现类似于 wait 和 notify 的功能。
Java 数据结构原理
List
- ArrayList:Object[] 数组,动态数组,线程不安全。通过无参构造初始数组容量为 0,当添加第一个元素时,才真正分配容量(默认 10)。当容量不足时,先判断按照 1.5 倍(位运算)的比例扩容能否满足最低容量要求,若能则以 1.5 倍扩容,否则以最低容量要求进行扩容。最大容量为 Integer.MAX_VALUE。
- Vector:Object[] 数组,内部方法主要是通过 synchronized 进行封装,调用 synchronized 方法可以保证线程安全,而部分方法并发调用不能。并发安全建议使用
Collections.synchronizedList
或者 CopyOnWriteArrayList 来实现。 - LinkedList:双向链表(JDK1.6 之前为循环链表,JDK1.7 取消了循环)。
- CopyOnWriteArrayList:线程安全且读操作无锁的 ArrayList,写操作则通过创建底层数组的新副本来实现,首先将当前容器复制一份,然后在新副本上执行写操作,结束之后再将原容器的引用指向新容器,是一种读写分离的并发策略。
Map
HashMap 和 LinkedHashMap K/V 均可为 NULL,ConcurrentHashMap 和 HashTable K/V 均不可为 NULL,TreeMap 只有 V 可为 NULL。线程安全的是 ConcurrentHashMap 和 HashTable。其中 HashMap 和 ConcurrentHashMap 扩容参数均为 0.75,节点个数达到数组长度 0.75 就会自动扩容。HashTable 为了保证线程安全导致效率极低已被弃用;TreeMap 结构基于红黑树,key 是有序的;LinkedHashMap 保证插入顺序是有序的。
HashMap 和 ConcurrentHashMap 在 JDK 7/8 之间升级改动较大,具体说明如下:
- 结构方面,JDK7 HashMap 结构为数组+链表;JDK8 HashMap 结构为数组+链表+红黑树,链表转红黑树的阈值默认为 8;
- 扩容方面,JDK7 HashMap 在并发环境下扩容可能产生循环链表导致死循环;JDK8 HashMap 优化了扩容方法解决了此问题;
- 并发方面,JDK7 ConcurrentHashMap 使用了分段锁(继承于 ReentrantLock)结构,写时首先一次哈希定位分段锁,若不存在则通过 CAS 写入,否则自旋拿锁(超时则阻塞),然后二次哈希定位 key 完成写入,读时无需加锁;JDK8 ConcurrentHashMap 使用 Synchronized 替代了分段锁,写时首先定位 key,如果为空这通过 CAS 写入,否则利用 Synchronized 完成写入,读时也无需加锁;
- 长度方面,JDK7 ConcurrentHashMap 使用不加锁的模式去尝试多次(最多三次)计算 size,比较前后两次计算的结果,结果一致就认为计算的结果是准确的,否则就会给每个 Segment 加上锁,然后计算 ConcurrentHashMap 的 size 返回;JDK8 ConcurrentHashMap 在写时就计算了长度,获取则直接返回结果。
重要文章:
Set
- HashSet(无序,唯一): 基于 HashMap 实现的,底层采用 HashMap 来保存元素。数据是无序的,可以放入 null,但只能放入一个 null。
- LinkedHashSet: LinkedHashSet 是 HashSet 的子类,并且其内部是通过 LinkedHashMap 来实现的。
- TreeSet(有序,唯一): 红黑树(自平衡的排序二叉树),Treeset 中的数据是自动排好序的,不允许放入 null 值;
Queue
TODO
- PriorityQueue: Object[] 数组来实现小顶堆。
- DelayQueue:PriorityQueue。详细可以查看:DelayQueue 源码分析。
- ArrayDeque: 可扩容动态双向数组。
- ArrayBlockingQueue:
Java 多线程相关
Runnable vs Callable
- Runnable 提供 run 方法,无法抛出异常,而 Callable 提供 call 方法,可直接抛出异常。
- Runnable 的 run 方法无返回值,Callable 的 call 方法提供返回值用来表示任务运行的结果。
- Runnable 可以作为 Thread 构造器的参数,通过开启新的线程来执行,也可以通过线程池来执行。而 Callable 只能通过线程池执行。
ps: 线程池的 submit 方法可以提交 Runnable 或 Callable 的任务,并返回一个 Future 对象,而 execute 只可以提交 Runnable 的任务,但通过 FutureTask 包装 Callable 的任务可以解决这个问题。
Java 线程状态
Java 线程的生命周期中,存在几种状态。在 Thread 类里有一个枚举类型 State,定义了线程的几种状态,分别有:
- NEW: 线程创建之后,但是还没有启动(not yet started)
- RUNNABLE: 正在 Java 虚拟机下跑任务的线程的状态。在 RUNNABLE 状态下的线程可能会处于等待状态, 因为它正在等待一些系统资源的释放,比如 IO
- BLOCKED: 阻塞状态,等待锁的释放,比如线程 A 进入了一个 synchronized 方法,线程 B 也想进入这个方法,但是这个方法的锁已经被线程 A 获取了,这个时候线程 B 就处于 BLOCKED 状态
- WAITING: 等待状态,处于等待状态的线程是由于执行了如下 3 个方法中的任意方法:
- Object.wait(),并且没有使用 timeout 参数
- Thread.join(),没有使用 timeout 参数
- LockSupport 的 park 方法。 处于 waiting 状态的线程会等待另外一个线程处理特殊的行为
- TIMED_WAITING: 有等待时间的等待状态,比如调用了以下几个方法中的任意方法,并且指定了等待时间,线程就会处于这个状态:
- Thread.sleep()
- Object.wait(),带有时间
- Thread.join(),带有时间
- LockSupport 的 parkNanos/parkUntil 方法,带有时间
- TERMINATED: 线程中止的状态,这个线程已经完整地执行了它的任务
线程池
线程池机制
线程池是一种管理和重用线程资源的机制。通常情况下,每个任务都需要一个独立的线程来执行。当任务变得越来越多,每个任务都创建一个新线程就会导致系统负荷过重。这时候线程池的使用就能很好地解决这个问题。
线程池维护了一组空闲线程,当有任务需要执行时,就从线程池中选择一个空闲的线程执行任务,当任务执行完成后,这个线程就会被重新放回线程池,供下一次任务使用,这样可以节省线程创建和销毁的时间成本,提高系统的执行效率和响应速度。
线程池参数
- 核心线程数(corePoolSize):该参数指定核心线程池中线程的数量。当提交一个新任务时,如果当前线程池中的线程数少于核心线程数,那么就会创建新的线程。即使其他空闲的非核心线程可以处理新任务,也会继续创建线程,达到核心线程池大小。如果设置为 0,则任务会不断地加入队列,并在工作线程可用时立即执行。
- 最大线程数(maximumPoolSize):该参数指定总线程池大小,包括核心线程池和非核心线程池。在任务队列满了的情况下,可以创建的最大线程数。如果此时运行的线程数已经等于了最大线程数,则提交的任务会根据选择的拒绝策略进行处理。
- 线程存活时间(keepAliveTime):当线程池中的数量大于核心线程数时多余空闲线程的最长存活时间。
- 任务队列(workQueue):任务队列是存储被提交但尚未被执行的任务的阻塞队列。常用的任务队列有如下几类:
- ArrayBlockingQueue:基于数组的有限队列,可以指定容量。
- LinkedBlockingQueue:基于链表的无限队列,可以无限扩展。
- PriorityBlockingQueue:优先级队列,可以自定义排列顺序。
- SynchronousQueue:同步队列,不存储数据,只在提交和取出数据时传递数据。
- 拒绝策略(RejectedExecutionHandler):拒绝策略是当任务队列满了且由于线程池达到最大线程数无法再创建新线程处理新提交的任务时,需要执行拒绝策略来处理这些任务。提供了几种预定义的拒绝策略:
- AbortPolicy:直接抛出异常,默认策略。
- CallerRunsPolicy:主线程执行该任务。
- DiscardOldestPolicy:丢弃队列中最老的任务,然后重新尝试执行当前任务。
- DiscardPolicy:默默丢弃提交的任务,没有异常。
线程池性能指标
在对线程池进行调优和性能优化时,监控线程池的性能指标是非常重要的。通过实时监控这些指标,我们可以了解线程池的工作状态、性能瓶颈以及潜在的问题,从而及时采取措施进行优化。以下是一些常用的线程池性能指标:
- 监控线程池的任务执行时间
- 平均任务执行时间:计算所有任务的执行时间总和除以任务数量,可以衡量任务的平均执行效率。
- 最大任务执行时间:记录任务中执行时间最长的任务,可以帮助发现执行效率低下的任务。
- 监控线程池的线程利用率
- 线程利用率:计算正在执行任务的线程数量与线程池的最大线程数之间的比例,可以判断线程池的工作负载情况。
- 空闲线程比例:计算空闲线程数量与线程池的最大线程数之间的比例,可以评估线程池的空闲资源情况。
- 监控线程池的队列长度
- 队列长度:记录当前等待执行的任务数量,可以了解线程池中待执行的任务数目。
- 队列满的次数:统计队列满的次数,如果频繁发生队列满的情况,可能需要调整队列的容量或者拒绝策略。
以上指标可以通过监控工具或者自定义的代码来实现。通过对这些指标的监控和分析,我们可以及时发现线程池的性能瓶颈和问题,以便进行优化和调整。
线程池调优
- 根据任务特点调整线程池核心线程数和最大线程数
- 如果是 CPU 密集,CPU 占用时间短,利用率高,建议减少线程数
- 如果是 IO 密集,CPU 占用少,IO 占用时间较多,活跃任务较多,为减少上下文切换,建议增加线程数,具体需要根据 IO 等待时间而定
- 批量提交任务,从而减少线程切换开销。将多个任务打包成一个批次,一次性提交给线程池,减少线程切换的开销。
- 避免任务过多导致的竞争和线程饥饿问题:如果任务过多,可能会导致线程池中的线程竞争资源,从而影响性能。可以适当调整线程池的大小和任务队列的容量,以避免任务过多导致的竞争和线程饥饿问题。
- 线程池参数最好从配置文件中来读取,同时建议进行对线程池相关参数进行监控和报警,方便线上快速发现、配置和修复问题。
对于核心线程数和最大线程数的具体大小,如果不是很确定,可以参照一些经验值。以 N 代表 CPU 核数,对于核心线程数,IO 密集型任务线程数经验值是 2N,CPU 密集型任务配置线程数经验值是 N + 1。而最大线程数则没有确定的值,一般为 N 的数倍,应该根据任务特点以及任务队列的类型、容量而定。
此外,对于混合型的任务,一般可以根据经验公式来确定核心线程数:
1核心线程数 = CPU 核心数 * (1 + IO 耗时 / CPU 耗时)
其实对于线程数应该根据实际情况比如服务器的特点,服务的延迟要求等,通过压力测试来找到最佳 tps/qps、最佳 cpu 利用率等,然后才可能找到最佳的线程数配置。对于开发者而言,可以先简单根据经验进行配置,后续优化时再确定这些具体的参数。
流程一般是这样:
- 分析当前主机上,有没有其他进程干扰;
- 分析当前JVM进程上,有没有其他运行中或可能运行的线程;
- 设定目标
- 目标 CPU 利用率 - 我最高能容忍我的 CPU 飙到多少?
- 目标 GC 频率/暂停时间 - 多线程执行后,GC 频率会增高,最大能容忍到什么频率,每次暂停时间多少?
- 执行效率 - 比如批处理时,我单位时间内要开多少线程才能及时处理完毕
- 执行时间 - 比如希望任务在多长时间内执行完成,这个需要根据具体的业务、客户的特点来决定
- ……
- 梳理链路关键点,是否有卡脖子的点,因为如果线程数过多,链路上某些节点资源有限可能会导致大量的线程在等待资源(比如三方接口限流,连接池数量有限,中间件压力过大无法支撑等)
- 不断的增加/减少线程数来测试,按最高的要求去测试,最终获得一个“满足要求”的线程数
重要文章:
Spring 相关
SpringBoot 启动流程
- 初始化监听器,并开启监听器进行事件监听。
- 根据监听器和参数来创建运行环境。
- 根据 WebApplicationType 创建 Spring 容器。
- 进行容器前置处理。将启动类注入容器,配置一些容器初始化选项。
- 刷新容器,主要用于进行自动装配和初始化容器。
- 进行容器后置处理,可以重写后置处理方法,实现自定义需求。
- 发出结束执行的事件通知。
- 执行 Runners,调用项目中自定义的执行器 XxxRunner 类,使得在项目启动完成后立即执行一些特定程序。
- 返回 Spring 容器。
Spring 自动装配原理
Spring Boot 通过 @EnableAutoConfiguration
开启自动装配,通过 SpringFactoriesLoader 最终加载 META-INF/spring.factories
中的自动配置类实现自动装配,自动配置类其实就是通过 @ConditionalXxx
按需加载的配置类,想要其生效必须引入 spring-boot-starter-xxx 包实现起步依赖。
Spring 事务传播机制
事务传播机制,就是根据当前是否处在一个事务中,然后决定重新开启新事务、使用当前已有事务,或者抛出异常等其他操作。
SpringMVC 处理请求流程
DispatcherServlet -> HandlerMapping -> HandlerExecutionChain [HandlerInterceptor -> Handler -> HandlerInterceptor -> (…)]
-> ModelAndView -> ViewResolver -> View -> DispatcherServlet
Spring 解决循环依赖
Spring 解决循环依赖的方法是通过提前暴露半成品对象(Early-Stage Object)来解决,技术核心主要依赖于 BeanPostProcessor 和三级缓存。
当 Spring 创建一个 Bean 的时候,它会先创建该 Bean 的半成品对象,然后再注入该 Bean 所依赖的其他 Bean。当所有的 Bean 都被创建并注入完成后,Spring 再完成这些半成品对象的初始化,从而解决了循环依赖的问题。
Spring 解决循环依赖的过程大致分为三个步骤:
- 创建 Bean 的半成品对象,并将其添加到缓存中。
- 注入该 Bean 所依赖的其他 Bean。
- 完成 Bean 的初始化,将半成品对象转换为完整的 Bean 对象。
Maven 相关
mvn 命令区别
- dependency:resolve 只是下载依赖
- clean 清理项目建的临时文件,一般是模块下的 target 目录
- compile 完成了项目编译功能,生成 target 目录,在该目录中包含一个 classes 文件夹,里面全是生成的 class 文件及字节码文件
- package 完成了项目编译、单元测试、打包功能
- install 完成了项目编译、单元测试、打包功能,同时把打好的可执行 jar 包(war 包或其它形式的包)布署到本地 maven 仓库
- deploy 完成了项目编译、单元测试、打包功能,同时把打好的可执行 jar 包(war 包或其它形式的包)布署到本地 maven 仓库和远程 maven 私服仓库
1# 下载新增依赖一般可以用这个命令
2mvn package
3# 强制重新加载依赖
4mvn -U clean package -Dmaven.test.skip=true
Maven 依赖调用顺序
- 最短路径原则,同一 pom 中不同依赖包的同一子依赖包,依赖深度小的优先
- 优先声明原则,同一 pom 中不同依赖包 A, B 的同一子依赖包深度相同,依赖包 A, B 谁先声明谁优先
- 父依赖优先原则,子 pom 中若未显式声明但间接依赖了 A,且同时父 pom 声明了 A,那么父依赖优先
其它
二进制数中的右移计算问题
首先查看如下 java 代码:
1int x = -5 >> 2;
2int y = x >>> 3;
3int z = x >> 3;
4System.out.println(x); // -2
5System.out.println(y); // 536870911
6System.out.println(z); // -1
其中:
- “»” 为右移运算符,num » 1,相当于 num 除以 2
- “»>” 为无符号右移运算符,忽略符号位,空位都以 0 补齐
- 计算机中负数用补码表示,正数的补码就是其本身,负数的补码是在其原码的基础上,符号位不变,其余各位取反,最后+1
-5 即 5 的补码,10000101 -> 11111010 -> 11111011
-5 » 2: 11111110 (-2)
-2 »> 3: 11111110 -> 0001…1(int 32 位,高位补 3 个 0,低位是 29 个 1, 即 2^29-1=536870911)
-2 » 3: 11111110 -> 11111111 (-1)
List.toArray(T[] a) 返回的是什么?
测试如下:
1List<String> stringList = new ArrayList<>();
2stringList.add("111");
3stringList.add("222");
4stringList.add("333");
5String[] stringListArray1 = stringList.toArray(new String[0]);
6String[] stringListArray2 = stringList.toArray(new String[0]);
7// 是引用
8System.out.println(stringListArray1[0] == stringListArray2[0]); // true
9System.out.println(stringListArray1[0] == stringList.get(0)); // true
10stringList.set(0, "999"); // 本质是 = 操作,也就是 new String 了
11System.out.println(stringList.get(0)); // 999
12System.out.println(stringListArray1[0]); // 111
13System.out.println(stringListArray2[0]); // 111
14
15List<Pet> petList = new ArrayList<>();
16petList.add(new Pet("jack", Color.YELLOW));
17petList.add(new Pet("mary", Color.GREEN));
18Pet[] petListArray1 = petList.toArray(new Pet[0]);
19Pet[] petListArray2 = petList.toArray(new Pet[0]);
20// petListArray1[0].setName("tom");
21petList.get(0).setName("tom");
22// 是引用
23System.out.println(petList.get(0) == petListArray1[0]); // true
24System.out.println(petList.get(0)); // Pet(name=tom, color=YELLOW)
25System.out.println(petListArray1[0]); // Pet(name=tom, color=YELLOW)
26System.out.println(petListArray2[0]); // Pet(name=tom, color=YELLOW)
List.toArray(T[] a)
返回的是引用,而且是对每个元素的引用,对原对象或新对象进行操作都会同时更改两个地方的值。
Arrays.asList() 静态内部类大坑
测试如下:
1String[] strings = new String[]{"aaa", "bbb"};
2List<String> lst = Arrays.asList(strings);
3// 返回的是 Arrays 的内部静态类 ArrayList, 它不支持 add 操作
4// 且 strings 作为 ArrayList 的一个成员变量存在
5System.out.println(lst.get(0) == strings[0]); // true // 返回的是对整个数组的引用
6lst.set(0, "vvv");
7System.out.println(strings[0]); // vvv
8strings[0] = "kkk";
9System.out.println(lst.get(0)); // kkk
10
11// lst.add("ccc"); // err
看看 Arrays 源码可以找到 ArrayList 这个静态内部类:
1public class Arrays {
2 // ...
3 private static class ArrayList<E> extends AbstractList<E>
4 implements RandomAccess, java.io.Serializable
5 {
6 private static final long serialVersionUID = -2764017481108945198L;
7 private final E[] a;
8
9 ArrayList(E[] array) {
10 a = Objects.requireNonNull(array);
11 }
12 }
13 // ...
14}
实际操作中建议别用 Arrays.asList() 这个方法,太容易出问题了。
Java 序列化为什么要实现 Serializable 接口?
当要写入的对象是 String、Array、Enum 类型的对象时,由于这些对象内部已经实现了 Serializable 接口,所以可以正常序列化,但是对于其他对象,如果没有实现该接口就进行序列化,就会抛出 NotSerializableException 异常。
PS: 为什么要显示指定 serialVersionUID 的值呢?
因为序列化对象时,如果不显示的设置 serialVersionUID,Java 在序列化时会根据对象属性自动的生成一个 serialVersionUID,再进行存储或用作网络传输。
在反序列化时,会根据对象属性自动再生成一个新的 serialVersionUID,和序列化时生成的 serialVersionUID 进行比对,两个 serialVersionUID 相同则反序列化成功,否则就会抛异常。
而当显示的设置 serialVersionUID 后,Java 在序列化和反序列化对象时,生成的 serialVersionUID 都为我们设定的 serialVersionUID,这样就保证了反序列化的成功。
为什么 Lambda 表达式(匿名类) 不能访问非 final 的局部变量呢?
Lambda 对于实例变量、静态变量不限制。
对于局部变量限制,必须是 final 类型,即使没有声明为 final 类型,后续这个变量也不可以被改变,如果是引用类型的属性的值,或者是 list 这种的增删可以,但重新赋值整个对象也不行。
原因:
- 局部变量有一个特点,存在于局部变量表中,属于线程私有,不共享。随着作用域的结束,可能会被内存回收。
- Lambda 是匿名内部类,如果和主线程运行时使用了不同的线程,那么很有可能在主线程结束后,局部变量已经销毁,或者发生了更改,那么就会导致实际使用和真实的是不一致的。
- 实例变量或者静态变量存放在堆中,线程共享,因此不受这个限制。
JDK JRE JVM 关系
为什么说 Java 语言“编译与解释并存”?
高级编程语言按照程序的执行方式分为编译型和解释型两种。
- 编译型语言是指编译器针对特定的操作系统将源代码一次性翻译成可被该平台执行的机器码,不能跨平台。
- 解释型语言是指解释器对源程序逐行解释成特定平台的机器码并立即执行,一次编写,到处执行。
Java 语言既具有编译型语言的特征,也具有解释型语言的特征,因为 Java 程序要经过先编译,后解释两个步骤。
由 Java 编写的程序需要先经过编译步骤,生成字节码(*.class 文件),这种字节码必须再经过 JVM,解释成操作系统能识别的机器码,在由操作系统执行。
因此,我们可以认为 Java 语言编译与解释并存。
重载(overload)和重写(override)的区别?
方法的重载和重写都是实现多态的方式,区别在于前者实现的是编译时的多态性,而后者实现的是运行时的多态性。
重载发生在一个类中,同名的方法如果有不同的参数列表(参数类型不同、参数个数不同或者二者都不同)则视为重载;
重写发生在子类与父类之间,重写要求子类被重写方法与父类被重写方法有相同的返回类型,比父类被重写方法更好访问,不能比父类被重写方法声明更多的异常(里氏代换原则)。
方法重载的规则:
- 方法名一致,参数列表中参数的顺序,类型,个数不同。
- 重载与方法的返回值无关,存在于父类和子类,同类中。
- 可以抛出不同的异常,可以有不同修饰符。
抽象类(abstract class)和接口(interface)有什么区别?
- 接口的方法默认是 public,所有方法在接口中不能有实现(Java8开始接口方法可以有默认实现),而抽象类可以有非抽象的方法。
- 接口中除了 static、final 变量,不能有其他变量,而抽象类中则不⼀定。
- ⼀个类可以实现多个接口,但只能实现⼀个抽象类。接口自己本身可以通过extends关键字扩展多个接口。
- 接口方法默认修饰符是 public,抽象方法可以有 public、protected 和 default 这些修饰符(抽象方法就是为了被重写所以不能使用 private 关键字修饰)。
- 从设计层面来说,抽象是对类的抽象,是⼀种模板设计,而接口是对行为的抽象,是⼀种行为的规范。
注意jdk7~jdk9中接口的变化:
- 在 jdk 7 或更早版本中,接口里面只能有常量变量和抽象方法。这些接口方法必须由选择实现接口的类实现。
- jdk 8 的时候接口可以有默认方法和静态方法功能。
- jdk 9 在接口中引⼊了私有方法和私有静态方法
try 和 finally 中的 return
- 若 try 中有 return,而 finally 中无 return,会先将值暂存,无论 finally 语句中对该值做什么处理,最终返回的都是 try 语句中的暂存值。另外,finally 语句中执行的语句先于 try 的 return 之前执行。
- 当 try 与 finally 语句中均有 return 语句,会忽略 try 中 return,执行 finally 的 return。注意如果 try 的 return 语句是
return ++x
,则++x
是会执行的。
验证如下:
1
2public class TryReturnFinallyTest {
3 public static int test1(int x) {
4 try {
5 x += 1;
6 return ++x;
7 } catch (Exception e) {
8 e.printStackTrace();
9 } finally {
10 x += 5;
11 System.out.println("finally: " + x);
12 }
13 return x;
14 }
15 public static int test2(int x) {
16 try {
17 x += 1;
18 return ++x;
19 } catch (Exception e) {
20 e.printStackTrace();
21 } finally {
22 x += 5;
23 System.out.println("finally");
24 return x;
25 }
26 }
27
28 public static void main(String[] args) {
29 System.out.println(TryReturnFinallyTest.test1(0));
30 System.out.println("-----------");
31 System.out.println(TryReturnFinallyTest.test2(0));
32 /**
33 * finally: 7
34 * 2
35 * -----------
36 * finally
37 * 7
38 */
39 }
40}
String 和 StringBuilder、StringBuffer 的区别?
- String:String 的值被创建后不能修改,任何对 String 的修改都会引发新的 String 对象的生成。
- StringBuffer:跟 String 类似,但是值可以被修改,使用 synchronized 来保证线程安全。
- StringBuilder:StringBuffer 的非线程安全版本,性能上更高一些。
在Java8 时JDK 对“+”号拼接进行了优化,String间通过"+“来拼接的方式会被优化为基于 StringBuilder 的 append 方法进行处理。Java 会在编译期对“+”号进行处理。
关于 Integer
实例引入
1public static void main(String[] args) {
2 Integer a = 127;
3 Integer b = 127;
4 Integer b1 = new Integer(127);
5 System.out.println(a == b); //true
6 System.out.println(b == b1); //false
7
8 Integer c = 128;
9 Integer d = 128;
10 System.out.println(c == d); //false
11}
解释如下:
Integer a = 127
这种赋值,是用到了 Integer 自动装箱的机制。自动装箱的时候会去缓存池里取 Integer 对象,没有取到才会创建新的对象。
如果整型字面量的值在 -128 到 127 之间,那么自动装箱时不会 new 新的 Integer 对象,而是直接引用缓存池中的 Integer 对象,超过范围a1 == b1
的结果是 false。
理解 Integer 缓存
因为根据实践发现大部分的数据操作都集中在值比较小的范围,因此 Integer 搞了个缓存池,默认范围是 -128 到 127,可以根据通过设置JVM-XX:AutoBoxCacheMax=
来修改缓存的最大值,最小值改不了。
实现的原理是 int 在自动装箱的时候会调用Integer.valueOf
,进而用到了IntegerCache。
Integer.valueOf
就是判断下值是否在缓存范围之内,如果是的话去 IntegerCache 中取,不是的话就创建一个新的 Integer 对象。
IntegerCache 是一个静态内部类, 在静态块中会初始化好缓存值。
1private static class IntegerCache {
2 ……
3 static {
4 //创建Integer对象存储
5 for(int k = 0; k < cache.length; k++)
6 cache[k] = new Integer(j++);
7 ……
8 }
9}
BIO、NIO、AIO
- BIO (blocking I/O): 就是传统的 IO,同步阻塞。
- NIO (non-blocking IO): 同步非阻塞,服务器端用一个线程处理多个连接,客户端发送的连接请求会注册到多路复用器上,多路复用器轮询到连接有 IO 请求就进行处理。NIO 的数据是面向缓冲区Buffer的,必须从 Buffer 中读取或写入。
每个 Channel 对应一个 Buffer。
Selector 对应一个线程,一个线程对应多个 Channel。
Selector 会根据不同的事件,在各个通道上切换。
Buffer 是内存块,底层是数据。
- AIO:JDK 7 引入了 Asynchronous I/O,是异步不阻塞的 IO。在进行 I/O 编程中,常用到两种模式:Reactor 和 Proactor。Java 的 NIO 就是 Reactor,当有事件触发时,服务器端得到通知,进行相应的处理,完成后才通知服务端程序启动线程去处理,一般适用于连接数较多且连接时间较长的应用。