Home Basics of Java
Post
Cancel

Basics of Java

基本知识

三大特点:封装继承多态。

语法糖:switch支持String、泛型、自动拆装箱、变长参数、枚举、内部类、条件编译、断言、数值下划线、for-each、try-with-resources、Lambda表达式

装箱:Integer i = Integer.valueOf(10), 拆箱:int n = i.intValue()

反射:指动态获取的信息以及动态调用对象的方法的功能。在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意一个方法和属性

Object 类的方法

  1. clone()
  2. getClass()
  3. toString()
  4. finalize()
  5. equals()
  6. hashCode()
  7. wait()
  8. notify()
  9. notifyAll()

双等号和 equals() 的区别

  1. 针对 String 引用数据类型,== 是用来判断两个对象的引用是否相等,而 equals() 是判断两个对象的是否相等。
  2. 基本数据类型没有 equals() 方法
  3. 针对自定义对象而言,如果没有重写 equals() 方法,则两者没有区别,equals() 内部是通过 == 来判断。

重写 equals() 为什么一定要重写 hashCode()

  1. 对比两个对象是否相等时,先使用 hashCode() 判断,再用 equals() 判断
  2. 相同的对象一定要有相同的哈希值

假设重写 equals() 但没有重写 hashCode(),并且因为 Set 存储的是不重复的对象,依据 hashCode 和 equals 进行先后判断,如果没有重写 hashCode(),则直接认为两个对象不相等。

为什么不直接使用 hashCode 就确定两个对象是否相等呢

这是因为不同对象的 hashCode 可能相同;但 hashCode 不同的对象一定不相等,所以使用 hashCode 可以起到快速初次判断对象是否相等的作用。

内存结构

运行时数据区域包含线程私有的程序计数器、虚拟机栈、本地方法栈,线程共享的堆(包含字符串常量池)。直接内存(堆外内存)包含元空间(包含类常量池和运行时常量池)。

  • 程序计数器:与操作系统中的程序计数器类似,为了线程切换后能恢复到正确的执行位置,是唯一一个不会出现 OutOfMemoryError 的内存区域。
  • 虚拟机栈:以帧为单位,帧由局部变量表、操作数栈、动态链接、方法返回地址组成。每一次方法调用都会有一个对应的栈帧被压入栈中,每一个方法调用结束后,都会有一个栈帧被弹出。
    • 局部变量表:主要存放了编译期可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)
    • 操作数栈:用于存放方法执行过程中产生的中间计算结果,也存放计算过程中产生的临时变量。
    • 动态链接:将常量池中指向方法的符号引用转化为其在内存地址中的直接引用。与类加载中的解析类似。
    • 方法返回地址:顾名思义。
  • 本地方法栈:和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法(机器码)服务
  • 堆:唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
  • 字符串常量池:简单理解为用C++实现的默认固定大小为1009的HashTable。在每个VM中只有一份,存放的是字符串常量的引用值 。关于常量池中的String类型的数据,String#intern 的用法
  • 类常量池:每个java文件被编译成class文件后会有一项常量池,用于存放编译器生成的字面量符号引用。在编译阶段,存放的是常量的符号引用
  • 运行时常量池:是在类加载完成后,将每个class常量池中的符号引用值转存到运行时常量池中,也就是说,每个class都有一个运行时常量池,类在解析阶段,将符号引用替换成直接引用,与字符串常量池中的引用值保持一致。

方法区

永久代和元空间的区别

在 hotpot 虚拟机中,JDK7以前的方法区是用永久代实现的;在JDK8后,方法区用元空间实现的。最大的区别:元空间位于本地内存上;而永久代位于虚拟机内存中,与堆是连续的一块内存。

垃圾回收

只有 Full GC 时执行垃圾回收,主要回收两部分内容:

  1. 常量池中废弃的常量
  2. 不再使用的类型,类需要同时满足下面 3 个条件才能被卸载:
    1. 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
    2. 加载该类的 ClassLoader 已经被回收。
    3. 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

class常量池、字符串常量池和运行时常量池的区别

内存模型(JMM)

JMM 旨在提供一个统一的可参考的规范,屏蔽平台内存访问差异性。这个规范为多线程读写共享变量时如何与内存交互提供了规则和保证。并发编程中,程序会因为 CPU 多级缓存或指令重排序等出现问题,因此需要一些规范要保证并发编程的可靠性。

关键概念包括:

  • 主内存:表示所有线程都可以访问的共享内存。线程不能直接读写主内存中的变量。
  • 工作内存:每个线程都有自己的工作内存,线程的工作内存保存了该线程用到的变量和主内存共享变量的副本拷贝,线程对变量的操作都在工作内存中进行。当一个线程修改了自己工作内存中的变量时,它必须把这个变量的最新值写回到主内存中,以便其他线程可以看到这个最新的值。
  • 共享变量:这些变量可以被多个线程访问。它们可以是实例变量或静态变量。必须存储在主内存中。

JMM 为处理共享变量定义了三个特征(多线程中的概念):

  • 可见性:当一个线程修改共享变量的值,其他线程能够立即知道被修改了。当变量被 volatile 修饰时,这个变量被修改后会立刻刷新到主内存,当其它线程需要读取该变量时,会去主内存中读取新值。但普通变量读取的仍是旧值。
  • 原子性:一个操作是不可分割,不可中断的,一个线程在执行时不会被其他线程干扰。Synchronized 块之间的操作具有原子性
  • 顺序性:一个线程观察其他线程中的指令执行顺序,由于指令重排序的存在,该观察结果一般杂乱无序。

volatile

通过内存屏障来保证可见性的

  • 保证可见性,但不保证原子性!只是确保将变量的更新操作通知到其他线程。不能一定能保证线程安全。
  • 禁止指令重排,背景:为了提高性能,编译器和处理器常常会对指令重排。禁止指令重排避免了多线程环境下程序出现乱序执行的现象。

happens-before

happens-before原则定义如下:

  1. 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
  2. 两个操作之间存在happens-before关系,并不意味着一定要按照happens-before原则制定的顺序来执行。重排序之后的执行结果与按照happens-before关系来执行的结果一致即可。

as-if-serial

As-if-serial的意思是所有的语句都可以为了优化而被重排序,但是必须保证它们重排序后的结果和程序代码本身的应有结果是一致的。为保证as-if-serial语义,Java异常处理机制也会为重排序做一些特殊处理。

八种内存交互操作

内存屏障

  1. LoadLoad 屏障:对于这样的语句Load1,LoadLoad,Load2。在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
  2. StoreStore屏障:对于这样的语句Store1, StoreStore, Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
  3. LoadStore 屏障:对于这样的语句Load1, LoadStore,Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
  4. StoreLoad 屏障:对于这样的语句Store1, StoreLoad,Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。

在每个volatile读操作后插入LoadLoad屏障,在读操作后插入LoadStore屏障。

在每个volatile写操作的前面插入一个StoreStore屏障,后面插入一个SotreLoad屏障。

  

类的生命周期

  • 加载:通过类的全限定名(包名 + 类名)获取class文件的二进制字节流(通过类加载器来完成,其加载过程使用双亲委派模型),将其转化为方法区运行时的数据结构,最后在堆中实例化一个java.lang.Class对象,作为方法区中这个类的信息的入口。
  • 连接
    • 验证:确保被加载的类的正确性
    • 准备:为类的静态变量分配内存并设为jvm的默认值(不同于下文的初值,基本类型为零,引用类型为null,final修饰的常量为设定的值),对于非静态的变量,则不会为它们分配内存。
    • 解析:虚拟机将常量池中的符号引用替换为直接引用,主要针对类或接口,字段,类方法,方法类型等。举例:使用内存地址(直接引用)指向方法名(符号引用)代替方法名。
  • 初始化:按照顺序自上而下运行类中的变量赋值语句和静态语句,如果有父类,则首先按照顺序运行父类中的变量赋值语句和静态语句在类的初始化阶段,只会初始化与类相关的静态赋值语句和静态语句。类变量(静态变量)在方法区分配内存,并设置初值
  • 使用:包括主动引用和被动引用。直接引用就会触发类的初始化,其中包括以下四种情况:
    1. 通过new关键字实例化对象、读取或设置类的静态变量、调用类的静态方法。
    2. 初始化子类的时候,会触发父类的初始化。
    3. 作为程序入口直接运行时(也就是直接调用main方法)。
    4. 通过反射方式执行以上三种行为。
  • 卸载:需要同时满足以下三个条件:该类所有的实例都已经被回收,即Java堆中不存在该类的任何实例;加载该类的ClassLoader已经被回收;该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。单例对象不会被JVM垃圾回收,因为无法满足卸载的第一个条件,Java堆中会始终存在该单例的实例。

类加载器

线程上下文类加载器:破坏了“双亲委派模型”,可以在执行线程中抛弃双亲委派加载链模式,使程序可以逆向使用类加载器,例如SPI (Service Provider Interface)。SPI接口中的代码经常需要加载具体的实现类。SPI接口是Java核心库的一部分,由 启动类加载器(Bootstrap Classloader) 来加载,而实现类由 系统类加载器(AppClassLoader) 来加载。

双亲委派机制概念:双亲委派机制是指当一个类加载器收到某个类加载请求时,该类加载器首先会把请求委派给父类加载器。每个类加载器都是如此,它会先委托父类加载器在自己的搜索范围内找不到对应的类时,该类加载器才会尝试自己去加载。

双亲委派机制的作用:保证Java类的加载的一致性和安全性,避免了类的重复加载和恶意代码的替换。

Tomcat中的类加载器:

  • Tomcat自身所使用的类加载器,会加载jre的lib包及tomcat的lib包的类,遵循双亲委派机制。加载顺序:(1).先从缓存中加载;(2).如果没有,则从JVM的Bootstrap类加载器加载;(3).如果没有,则从父类加载器加载,加载顺序是AppClassLoader、Common、Shared。(4).如果没有,则从当前类加载器加载(按照WEB-INF/classes、WEB-INF/lib的顺序);
  • 每个Web应用程序用的,每个web应用程序都有自己专用的WebappClassLoader,优先加载/web-inf/lib下的jar中的class文件,这样就隔离了每个web应用程序的影响,不遵循双亲委派机制。加载顺序:(1).先从缓存中加载;(2).如果没有,则从JVM的Bootstrap类加载器加载;(3).如果没有,则从当前类加载器加载(按照WEB-INF/classes、WEB-INF/lib的顺序);(4).如果没有,则从父类加载器加载,由于父类加载器采用默认的委派模式,所以加载顺序是AppClassLoader、Common、Shared。

Java的SPI:SPI 的本质是将接口实现类的全限定名配置在文件中,并由服务加载器读取配置文件,加载实现类。当服务的提供者提供了服务接口的一种实现之后,在jar包的META-INF/services/目录里同时创建一个以服务接口命名的文件。该文件里就是实现该服务接口的具体实现类。而当外部程序装配这个模块的时候,就能通过该jar包META-INF/services/里的配置文件找到具体的实现类名,并装载实例化,完成模块的注入。

对象的创建过程

  1. 类加载检查
  2. 分配内存:指针碰撞或空闲列表
    • 当多个对象并发争抢空间时,有两种解决办法:CAS 和本地线程分配缓冲(TLAB,默认方式)
  3. 初始化零值
  4. 设置对象头
  5. 执行构造方法

对象的内存布局

  1. 对象头,两部分组成:存储自身运行时数据如哈希码,GC分代年龄;指向类的类型指针
  2. 实例数据,真正存储有效信息的部分
  3. 对齐填充,起占位作用

对象的定位访问(针对JVM虚拟机栈中的局部变量表)

  1. 句柄,Java 堆中将会划分出一块内存来作为句柄池,局部变量表 reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息。
  2. 直接指针,局部变量表里 reference 中存储的直接就是对象的地址。

垃圾回收

JVM触发GC时,首先会让所有的用户线程到达安全点SafePoint时阻塞,也就是STW,然后枚举根节点,即找到所有的GC Roots,通过可达性算法向下搜寻活跃对象,可达的对象就保留,不可达的对象就回收

可达性算法

  • 引用计数算法
  • 可达性分析

哪些对象可以作为GC Roots

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 本地方法栈(Native 方法)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 所有被同步锁持有的对象

内存分配和回收原则

  • 对象优先在Eden区分配
  • 大对象直接进入老年代
  • 长期存活的进入老年代

GC 分类

  • Partial GC
    • Minor GC:只对新生代进行垃圾收集
    • Major GC:只对老年代进行垃圾收集
    • Mixed GC:整个新生代和部分老年代,只有G1收集器有
  • Full GC:整个Java堆和方法区

Full GC 触发条件

  1. 老年代空间不足
  2. 创建大对象,Eden 区域放不下大对象,直接进入老年代
  3. Minor GC 后,存活对象进入老年代
  4. 调用 system.gc(),系统会建议执行 FGC
  5. 空间分配担保机制失败,老年代连续可用空间不足

空间担保策略

空间担保策略是 JVM 的一种机制,发生 Minor GC 之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。如果小于,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小,如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的;如果小于或者HandlePromotionFailure=false,则改为进行一次Full GC。

垃圾收集算法

  • 标记——清除算法:顾名思义,标记可回收的对象并清除。
  • 标记——复制算法:将内存分成大小相同的两份,需要垃圾收集时,将存活的对象复制到另一份内存中,缺点:内存缩小为原来的一半。
  • 标记——整理算法:标记可回收的对象,将存活的对象向一端移动,适合老年代这种垃圾回收频率不高的场景。
  • 分代收集算法:在新生代和老年代不同的代用不同的垃圾收集算法。

垃圾收集器

  1. Serial 收集器,单线程、复制算法的新生代收集器
  2. ParNew 收集器,多线程、复制算法的新生代收集器,老年代采用Serial Old收集器
  3. Parallel Scavenge 收集器,多线程、复制算法的新生代收集器,高吞吐量。
  4. Serial Old 收集器,单线程、标记-整理算法的老年代收集器。
  5. Parallel Old 收集器,多线程、标记-整理算法的老年代收集器。
  6. CMS(Concurrent Mark Sweep) 收集器,标记-清除算法,以获取最短回收停顿时间为目标的收集器。JDK14正式移除。
  7. G1(Garbage-First) 收集器,标记-整理 + 复制算法,内存碎片的产生率大大降低。JDK9-JDK17的默认垃圾收集器。

G1收集器内存模型

垃圾收集器发展历程:

  • JDK8 默认 Parallel Scavenge(标记-复制) + Parallel Old(标记整理)
  • JDK9 默认 G1
  • JDK11 提出 ZGC
  • JDK14 CMS 被移除

CMS(Concurrent Mark Sweep)收集器: 基于标记-清除算法,在Minor GC时会暂停所有的应用线程,并以多线程的方式进行垃圾回收。在Full GC时不再暂停应用线程,而是使用若干个后台线程定期的对老年代空间进行扫描。

  • 步骤:
    1. 初始标记(CMS initial mark):有STW,但速度很快
    2. 并发标记(CMS concurrent mark):从GC Roots的直接关联对象开始遍历整个对象图
    3. 重新标记(CMS remark):STW,为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录;采用三色标记算法和增量更新避免漏标
    4. 并发清除(CMS concurrent sweep):清理删除掉标记阶段判断的已经死亡的对象
  • 优点:并发收集,低停顿
  • 缺点:
    1. 工作时会占用一部分CPU资源而导致用户程序变慢,降低总吞吐量
    2. 无法清除浮动垃圾
    3. 基于标记-清除算法会导致内存碎片不断增多,在分配大对象时有可能会提前触发一次Full GC。
    4. 停顿时间不可预期

Garbage First(G1)收集器:

  • 特点:引入分区的思路,弱化了分代的概念,并合理利用垃圾收集各个周期的资源。
  • 内存结构:堆内存被切分为多个固定大小的区域,最小为1M,最大为32M,默认2048份。
  • 内存分配:每个区域被标记为E、S、O和H,分别表示Eden,Survivor,Old,Humongous。Humongous区域是为了那些存储超过50%标准region大小的对象而设计的,它用来专门存放巨型对象。如果一个H区装不下一个巨型对象,那么G1会寻找连续的H分区来存储。为了能找到连续的H区,有时候不得不启动Full GC。
  • 执行特点:
    • 并行与并发:使用多个CPU核缩短Stop The World停顿时间。
    • 空间整合:从整体来看是基于“标记-整理”算法实现的收集器;从局部上来看是基于“标记-复制”算法实现的。
    • 可观测的停顿:能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。
    • G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来) 。
  • 步骤:
    1. 初始标记(Initial Marking),STW,标记一下 GC Roots 能直接关联到的对象。
    2. 并发标记(Concurrent Marking),从 GC Root 开始对堆中对象进行可达性分析,找到存活对象。
    3. 最终标记(Final Marking),为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,STW,但是可并行执行。
    4. 筛选回收(Live Data Counting and Evacuation),对各个Region中的回收价值和成本进行排序并制定回收计划。
  • 优点:保持高回收率的同时,减少停顿

一个线程 OOM 后,其他线程仍能正常运行,因为 OOM 之前会 GC,释放掉资源。

JVM调优

  1. 选择合适的垃圾收集器:CPU单核,只能选择Serial;CPU多核,关注吞吐量 ,那么选择Parallel Scavenge(标记复制) + Paralle Old(标记整理)组合;CPU多核,关注用户停顿时间,JDK版本1.6或者1.7,内存小,那么选择CMS。CPU多核,关注用户停顿时间,JDK1.8及以上,JVM可用内存6G以上,那么选择G1。
  2. 调整内存大小,现象:垃圾收集频率非常频繁。
  3. 设置符合预期的停顿时间,现象:程序间接性的卡顿。参数:-XX:MaxGCPauseMillis
  4. 调整内存区域大小比率,现象:某一个区域的GC频繁,其他都正常。参数:-XX:SurvivorRatio=6, -XX:NewRatio=4
  5. 提升老年代年龄标准,现象:老年代频繁GC,每次回收的对象很多。参数:-XX:InitialTenuringThreshol=7
  6. 调整大对象的标准,现象:老年代频繁GC,每次回收的对象很多,而且单个对象的体积都比较大。参数:-XX:PretenureSizeThreshold=1000000//新生代可容纳的最大对象,大于则直接会分配到老年代,0代表没有限制。
  7. 调整GC的触发时机,现象:CMS收集器的情况下,G1 经常 Full GC,程序卡顿严重。
  8. 调整JVM本地内存(直接内存)大小,现象:堆内存空间充足,但是报OOM

调优的一条经验总结:

将新对象预留在新生代,由于 Full GC 的成本远高于 Minor GC,因此尽可能将对象分配在新生代是明智的做法,实际项目中根据 GC 日志分析新生代空间大小分配是否合理,适当通过“-Xmn”命令调节新生代大小,最大限度降低新对象直接进入老年代的情况。

Java IO

  1. BIO:同步阻塞IO,使用方便,但并发处理能力低
  2. NIO:同步非阻塞IO,适用于连接数目多且连接比较短(轻操作)的架构
  3. AIO:异步非阻塞IO,适用于连接数目多且连接比较长(重操作)的架构
  • 阻塞/非阻塞,是对同一个线程来说。关注的是程序在等待调用结果(消息,返回值)时的状态。阻塞调用是指调用结果返回之前,当前线程会被挂起。非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程。
  • 同步/异步,针对调用者与被调用者,它们是线程之间的关系。同步操作,调用者需要等待被调用者返回结果,才会进行下一步操作;异步操作,调用者不需要等待被调用者返回调用,即可进行下一步操作,被调用者通常依靠事件、回调等机制来通知调用者结果

NIO vs IO:

  • IO是面向字节流的,NIO是面向缓冲区的
  • IO流是阻塞的,NIO流是不阻塞的
  • 选择器:Java NIO的选择器允许一个单独的线程来监视多个输入通道

IO多路复用(事件驱动):一个线程不断轮询多个socket的状态,只有当socket真正有读写状态时,借用当前线程或者使用线程池额外启动线程,调用实际的IO读写操作。

Java NIO:

  • 实际上也是一种多路复用的IO。
  • 三大核心部分:Channel(通道) ,Buffer(缓冲区), Selector(选择器),Channel 负责传输(类比成铁路), Buffer 负责存取数据(类比成载着货物的火车)
    • Channel是双向的,数据总是从通道读到缓冲区或者从缓冲区中写入通道内。
  • 额外一个Selector线程,用于监听多个通道(Channel)的事件(比如:连接打开,数据到达),如果由事件发生,则获取事件并对每个事件进行相应的响应处理。

并发编程

一个 Java 程序的运行是 main 线程和多个其他线程同时运行。

多线程

CompletableFuture

CompletableFuture 最大的特点是支持函数式编程,可以通过回调的方式处理计算结果、链式组合异步任务等。当异步任务完成或者发生异常时,自动调用回调对象的回调方法。

wait() 和 sleep() 区别

  1. sleep() 是 Thread 的静态方法,wait() 是 Object 的普通 final 方法
  2. wait() 方法需要在synchronize块或者synchronize方法里调用,作用是释放锁,然而sleep不需要
  3. sleep() 是休眠,wait() 是挂起
  4. wait() 唤醒需要用 notify() 或者 notifyAll() ,而 sleep() 则是休眠一段时间自己就恢复
  5. 如果需要线程停顿,使用 sleep();使用 wait() 进行线程间的通信

synchronized

  1. 修饰普通方法/静态方法:通过 Access flags 的标记符实现同步
  2. 修饰代码块:通过 monitorenter 和 monitorexit 指令实现同步

底层都是通过对象头里 Mark Word 指向的对象监听器(Monitor)实现的,再底层是操作系统的互斥量(mutex)实现的

同一时刻只能有一个线程运行 synchronized(lock) 内的代码块,其他线程会否则阻塞。PS:获取锁(运行代码块),释放锁(阻塞代码块)

  1. wait(): 获取锁并使线程进入等待状态
  2. notify(): 随机唤醒一个在等待锁释放(wait())的线程
  3. notifyAll(): 唤醒所有正在等待锁释放(wait())的线程,

注意:notify() 或 notifyAll() 必须等到退出 synchronized() 或 wait() 后才释放锁!

1
2
3
4
5
6
7
8
synchronized (obj) {
    // 条件不满足
    while (condition does not hold) {
        obj.wait();
    }
    // 执行满足条件的代码
    obj.notifyAll();
}

or

1
2
3
4
5
6
7
8
9
10
synchronized (obj) {
  while (true){
    // 条件满足
    if (condition holds){
      // 执行满足条件的代码
      obj.notifyAll();
    }
    obj.wait();
  }
}

synchronized 可以用来修饰非静态方法(普通方法)、静态方法、代码块,锁住的是 class 对象的对象头!

run() 和 start() 的区别:

  • run(),调用普通方法,并不开启新线程。
  • start(),启动新线程,由JVM调用线程的run()方法。

Synchronized 和 Lock 的区别:

  1. Lock 是一个接口,Synchronized 是一个关键字
  2. Lock 需要手动释放锁,Synchronized 会自动释放锁
  3. Lock 可以是公平锁/非公平锁,Synchronized 只能是非公平锁
  4. Lock 有多种获取锁的方式,例如一定时间内获取不到会返回,Synchronized 获取不到锁一直会阻塞
  5. 性能方面,在竞争激烈的情况下,Lock 的性能会比 Synchronized 好

RenentantLock:

  • Lock:拿不到锁会一直等待
  • tryLock:去尝试获取锁,获取不到返回 false

守护线程(Daemon Thread)

Java 中的线程分为两种:

  1. 用户线程。
  2. 守护线程,其主要作用是为用户线程服务,比如垃圾回收线程,就是最典型的守护线程。在JVM中,所有非守护线程都执行完毕后,无论有没有守护线程,虚拟机都会自动退出。也就是守护线程拥有自动结束自己生命周期的特性,而非守护线程不具备这个特点。

集合框架:

  • Collection
    • Set
    • List
    • Queue
  • Map

线程安全的list:

  • vector
  • CopyOnWriteArrayList 读多写少的情况
  • Collections.synchronizedList() 读少写多的情况

HashMap 知识点

  • HashMap(JDK8): 乱序,数组+链表+红黑树+尾插法,链表长度大于8并且数组长度大于64时转红黑树,红黑树节点个数小于6转链表。JDK7时用数组+链表+头插法(可能造成循环链表)实现。
  • LinkedHashMap: 按插入顺序排序
  • TreeMap: 按字典序排序,因为是按字典序排序的,所以键肯定不能为null,值可以为null
  • IdentityHashMap:利用哈希表实现Map接口,不同的是,其比较键(或值)时,使用引用相等性代替对象相等性。
  • ConcurrentSkipListMap:基于跳表的线程安全的,实现快速查找的链表结构。

HashMap面试题

为什么计算哈希值采用低十六位和高十六位异或操作:

计算数组下标是与操作,只有低 n 位进行与操作,高位不参与任何操作 -> 为了增大散列程度减小哈希碰撞,因此将高十六位参与进哈希值的计算。

put() 的流程:

  1. hashcode的高十六位和低十六位进行异或运算
  2. (n - 1) & hash 计算数组下标,当 n 为二次幂时,等价于取余操作 (n - 1)& hash = hash % n
  3. 判断当前下标是否有元素,若有元素,使用尾插法。再根据链表长度判断是否需要转换成红黑树。

第一次扩容:执行第一次 put() 操作时,如果数组为空便会执行第一次扩容操作,初始化数组容量为默认大小。

扩容的过程:

  1. 将数组扩容成原数组的两倍
  2. 如果没有哈希冲突的节点,使用 e.hash&(newCap - 1) 计算新的桶位置
  3. 如果是链表,使用 e.hash&oldCap 并且判断是否等于零(本质上判断最高位结果是否为零)
  4. 如果最高位结果是0,桶位置不变
  5. 如果最高位结果是1,桶位置是原位置 + 扩容长度

Set:

  • HashSet: 乱序,基于HashMap实现
  • LinkedHashSet: 按插入的顺序排序,基于LinkedHashMap实现
  • TreeSet: 按字典序排序,基于红黑树

NULL key AND NULL value:

  • key
    • HashMap、LinkedHashMap 能使用 null key
    • ConcurrentHashMap、TreeMap、HashTable 不能使用 null key。
  • value
    • HashMap、LinkedHashMap、TreeMap 能使用 null value
    • Hashtable、ConcurrentHashMap 不能使用 null value。

为什么ConcureentHashMap的key和value都不能为null:

  • value不能为null:多线程情况下需要杜绝二义性。二义性是指当返回null时,无法判断是存在value为null的key还是不存在key从而返回null。因为单线程中可以使用 containsKey() 解决,但是多线程下无法使用同样的方法,因为可能会有其他线程进行其他操作影响返回值

ConcurrentHashMap JDK7 vs JDK8

  • JDK7: 数组 + 链表。先定位 Segment,再定位桶。底层结构是继承了ReentrantLock的Segment数组。可以看成是由线程安全的HashMap组成的一个map数组,数组的长度决定了支持的最大的并发量。
  • JDK8: 数组 + 链表 + 红黑树。可以直接定位到桶。链表中的元素超过8并且数组长度大于64后,将链表结构转换成红黑树。
    1. 使用 volatile 修饰Node的值和Next数组以保证值变化时对于其他线程是可见的
    2. 使用 table 数组的头结点作为 synchronized 的锁来保证写操作的安全
    3. 当头结点为 null 时,使用 CAS 操作来保证数据能正确的写入。

HashTable速度慢:使用synchronized对整个对象加锁。

JDK7:对整个数组进行分段(每段都是由若干个 hashEntry 对象组成的链表),每个分段都有一个 Segment 分段锁(继承 ReentrantLock 分段锁)。与hashtable相比,加锁粒度更细,但是初始化Segment数组长度后就无法扩容。ConcurrentHashMap 是一个二级哈希表。在一个总的哈希表下面,有若干个子哈希表。

JDK8:对table数组的头节点加锁(哈希桶为空时,使用CAS将新的Node写入哈希桶的首节点;哈希桶不为空时,使用synchronized对首节点加锁接着添加节点)

  • put:分两步,计算哈希值和一个死循环,循环步骤,
    1. first节点还没有初始化,所以初始化first节点,然后进入下次循环;
    2. first节点初始化了,但是为空,采用CAS方式把当前要put的值设置进这处,设置失败则进入下次循环,成功则保存成功,退出循环;
    3. 如果判断有其他线程正在对ConcurrentHashMap扩容(hash==MOVED),获取要去获取新的tab,进入下次循环;
    4. 找到了对应哈希桶的首节点f,直接对f加synchronized同步,然后判断f节点是链表结构还是红黑树结构,链表结构则遍历链表进行设置,红黑树则采用红黑树设置进去。设置成功后判断是否需要把链表结构转红黑树;
  • get: 大部分情况下不加锁,是通过 volatile 修饰 Node 成员 val 保证的。与 volatile 修饰桶数组无关,桶数组用 volatile 修饰主要是保证在数组扩容的时候保证可见性。仅当节点为红黑树、正在变色旋转、查询非头节点时,会加基于 CAS 实现的读写锁。

ThreadLocal: 提供线程内的局部变量,在多线程的环境中保证各个线程内的变量不同。将数据封闭在线程中而避免使用同步,即线程封闭。一个ThreadLocal对象即是一个线程局部变量。jdbc连接池就是用ThreadLocal,典型例子。以下使四种方法:

  • Object get():获取该线程局部变量的值。
  • void set(Object value):给该线程局部变量赋值。
  • protected Object initialValue():返回该线程局部变量的初始值,该方法是一个protected的方法,显然是为了让子类覆盖而设计的。
  • public void remove():将当前线程局部变量的值删除。

底层是 ThreadLocalMap 内部静态类,由数组实现,解决 hash 冲突的方式采用的是线性探测法

存在内存泄漏的原因:由于 ThreadLocalMap 的生命周期跟 Thread 一样长,如果没有手动删除对应 key 就会导致该 key 的value 永远无法被访问,造成内存泄漏

正确使用方法:

  1. 每次使用完ThreadLocal都调用它的remove()方法清除数据,防止 ThreadLocalMap 中 Entry 一直保持对 value 的强引用,导致 value 不能被回收
  2. 将ThreadLocal变量定义成private static,这样就一直存在ThreadLocal的强引用,也就能保证任何时候都能通过ThreadLocal的弱引用访问到Entry的value值,进而清除掉

线程池

线程池的七个参数:

  1. 核心线程数(corePoolSize): 核心线程数是线程池中保持活动状态的线程数。即使没有任务需要执行,核心线程也不会被回收。当有新任务提交时,如果核心线程都在忙碌,则会创建新的线程来处理任务。

  2. 最大线程数(maximumPoolSize): 最大线程数是线程池中允许的最大线程数。当工作队列满了并且活动线程数达到最大线程数时,如果还有新任务提交,线程池将创建新的线程来处理任务。但是,超过最大线程数的线程可能会导致资源消耗过大。

  3. 空闲线程存活时间(keepAliveTime): 空闲线程存活时间指的是非核心线程在没有任务执行时的最长存活时间。当线程池中的线程数超过核心线程数且空闲时间达到设定值时,多余的线程将被终止,直到线程池中的线程数不超过核心线程数。

  4. 时间单位(unit): 时间单位是用于表示核心线程数和空闲线程存活时间的单位。常见的时间单位包括秒、毫秒、分钟等。

  5. 工作队列(workQueue): 工作队列用于存储待执行的任务。当线程池中的线程都在忙碌时,新提交的任务将被添加到工作队列中等待执行。常见的工作队列类型有有界队列(如 ArrayBlockingQueue)和无界队列(如 LinkedBlockingQueue)等。

  6. 线程工厂(threadFactory): 线程工厂用于创建新线程。线程工厂提供了创建线程的方法,可以自定义线程的名称、优先级等属性。

  7. 拒绝策略(rejectedExecutionHandler): 拒绝策略定义了当线程池无法接受新任务时的处理策略。当工作队列已满且线程池中的线程数已达到最大线程数时,新任务将被拒绝执行。常见的拒绝策略有丢弃、丢弃最旧的任务、抛出异常等。

    1. AbortPolicy 拒绝任务并抛出一个异常 RejectedExecutionException
    2. DiscardPolicy 拒绝任务,不抛出异常。
    3. DiscardOldestPolicy 把老的任务丢掉,执行新任务。
    4. CallerRunsPolicy 直接调用线程处理该任务。

JDK四种线程池:

  • newCachedThreadPool,可根据需要创建新线程的线程池
  • newSingleThreadExecutor,单线程池
  • newFixedThreadPool,创建固定大小的线程池
  • newScheduledThreadPool,创建一个大小无限的线程池

线程池执行顺序:

  1. 首先判断 corePoolSize 是否已满,如果没有满,那么就去创建一个线程去执行该任务;否则请看下一步
  2. 如果线程池的核心线程数已满,那么就继续判断 BlockingQueue 是否已满,如果没满,那么就将任务放到任务队列中;否则请看下一步
  3. 如果任务队列已满,那么就判断线程池中的线程数量是否达到了maxumunPoolSize,如果没达到,那么就创建线程去执行该任务;否则请看下一步;
  4. 如果线程池已满,那么就根据拒绝策略来做出相应的处理;

简而言之:corePool->workQueue->maxPool

线程池被回收:线程池也是在堆中也是一个对象,一定要调用shutdown()

线程池何时回收线程:getTask()的返回值为null时

  1. 未调用shutdown(),并且当前工作线程数过多
  2. 调用shutdown(),缓冲队列中的线程为空

shutdown() 和 shutdownNow() 的区别:

  1. shutdown(): 将线程池状态置为SHUTDOWN,停止接受新的任务并且执行完所有任务后停止
  2. shutdownNow(): 将线程池状态置为STOP,停止接受新的任务、忽略队列中等待的任务、尝试中断(interrupt)正在运行的任务、返回未执行的任务列表

submit() 和 execute() 的区别:submit() 内部仍然是调用 execute() 方法,只不过 submit() 方法会获取任务返回值和异常信息。

核心线程数设置:

  1. CPU密集型任务:CPU核心数 + 1:这样设置线程池的大小能实现 CPU 的最优利用率。即使当计算密集型的线程偶尔由于页缺失故障或者其他原因暂停时,这个 “额外” 的线程也能确保CPU 的时装周期不会被浪费。
  2. IO密集型任务:CPU核心数 * 2
  3. 混合型任务:CPU核心数 * (1 + IO耗时/CPU耗时)

  1. 乐观锁,悲观锁:
    • 乐观锁,修改数据前比较数据是否被修改过。CAS,原子类的递增操作,适合频繁读
    • 悲观锁,加锁使其他线程无法修改。synchronized和lock的实现类,适合频繁写
  2. 自旋锁,非自旋锁:获取同步资源的锁失败,资源被占用(上下文切换,也就是线程的唤醒和阻塞是十分耗时的)
    • 自旋锁,不放弃CPU时间片,通过自旋等待锁的释放,但自旋超过一定次数(默认10次)仍没有获得锁,那么线程被挂起。线程竞争不激烈并且锁持有的时间不长时,可以使用自旋锁。
    • 非自旋锁,线程会进入阻塞状态
  3. 无锁,偏向锁,轻量级锁,重量级锁:指针对synchronized同步锁的状态,锁可以升级但不能降级。
    • 偏向锁,通过对比Mark Word中是否存储着指向当前线程的偏向锁以解决加锁问题,避免执行CAS操作来加锁和解锁,Java15放弃偏向锁。使用背景:锁不仅不存在多线程竞争,而且总是由同一个线程多次获取,那么在同一个线程反复获取所释放锁中,其中并还没有锁的竞争。
    • 轻量级锁,通过用CAS修改Mark Word操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。
    • 重量级锁,将除了拥有锁的线程以外的线程都阻塞。

锁升级的过程:当有线程访问同步块时,无锁升级为偏向锁;当有锁竞争时,升级为轻量级锁;当自旋十次失败,升级为重量级锁。

  1. 公平锁,非公平锁:
    • 公平锁,每个线程获取锁的顺序是按照线程访问锁的先后顺序获取的。
    • 非公平锁,线程获取锁时并不会遵循先来先得的规则,可以插队(并不是随意的插队,而是在合适的时机插队)。当后到的线程请求锁时,该锁恰好被释放,则该锁被后到的线程拥有。
  2. 可重入锁(递归锁),非可重入锁:ReentrantLock和synchronized都是可重入锁,NonReentrantLock是非可重入锁
    • 可重入锁,指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法可以再次获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。好处是避免死锁。
    • 非可重入锁,如果一个方法中获取锁并调用另外方法,那么在调用另外方法前需要释放锁。
  3. 独享锁(排它锁),共享锁
    • 独享锁,ReentrantLock、synchronized、ReentrantReadWriteLock的写锁
    • 共享锁,ReentrantReadWriteLock的读锁,可以再加共享锁但不可以加排他锁!

Synchronized(同步锁):属于独占锁、悲观锁、可重入锁、非公平锁。

ReentrantLock:继承了Lock类,两者都是可重入锁、悲观锁、独占锁、默认非公平锁。

AbstractQueuedSynchronizer(AQS)

该类是一个抽象类,采用模板方法的设计模式,规定了独占共享模式需要实现的方法。

简单解释:通过 CAS 修改 volatile 修饰的int值 state(该值代表竞争资源标识) + 一个存放等待锁的线程队列。其定义了两种资源共享模式:

  1. 独占式。ReentrantLock 是独占式的锁资源。初始化 state = 0,表示资源未被锁定,调用 lock() 方法时state的值加一,并且当 state = 0 才表明其他线程有机会获取锁。

  2. 共享式。ReentrantWriteLock 和 CountDownLatch 是共享锁模式。CountDownLatch 会将任务分成 N 个子任务,初始化 state = N,每个子线程完成任务后会减一,直到为零。

锁消除

指Java虚拟机在即时编译时,通过对运行上下的扫描,消除那些不可能存在共享资源竞争的锁。锁消除可以节约无意义的请求锁时间。

锁粗化

一直对某个对象反复加锁和解锁,频繁地进行互斥同步操作也会引起不必要的性能消耗。如果虚拟机检测到有一系列操作都是对某个对象反复加锁和解锁,会将加锁同步的范围粗化到整个操作序列的外部。

1
2
3
4
5
6
7
8
9
10
//==== 粗化前 ===
for(int i=0;i<n;i++){
    synchronized(lock){
    }
}
//==== 粗化后 ===
synchronized(lock){
    for(int i=0;i<n;i++){
    }
}

接口和抽象类的区别:

  • 相同点:
    • 都不能被实例化
    • 接口的实现类或抽象类的子类都只有实现了接口或抽象类中的方法后才能实例化
  • 不同点:
    • 接口是对行为的抽象(强调特定功能的实现),抽象类是对物体的抽象(强调所属关系)。
    • 接口只有定义,不能有方法的实现,java 1.8中可以定义default方法体,而抽象类可以有定义与实现,方法可在抽象类中实现。
    • 实现接口的关键字为implements,继承抽象类的关键字为extends。一个类可以实现多个接口,但一个类只能继承一个抽象类。所以,使用接口可以间接地实现多重继承。
    • 接口成员变量默认为public static final,必须赋初值,不能被修改;其所有的成员方法都是public、abstract的。抽象类中成员变量默认default,可在子类中被重新定义,也可被重新赋值;抽象方法被abstract修饰,不能被private、static、synchronized和native等修饰,必须以分号结尾,不带花括号。

static修饰词

java中静态属性和静态方法可以被继承,但是不能被重写,因此不能实现多态。

静态常量/静态变量/静态方法是用static修饰的常量/变量/方法,其从属于类。另外,static是不允许用来修饰局部变量的。

  • 静态方法可以调用静态变量,但不能调用非静态变量,因为静态方法在类加载时就分配了内存,而非静态变量是在对象实例化时才分配内存。

  • 非静态方法可以调用静态变量,也可以调用非静态变量。

静态初始化块、初始化块和构造方法的区别

执行顺序:静态初始化块 > 初始化块 > 构造方法

非静态初始化块(构造代码块):

作用:给对象进行初始化。对象一建立就运行,且优先于构造函数的运行。

与构造函数的区别:

非静态初始化块给所有对象进行统一初始化,构造函数只给对应对象初始化。

应用:将所有构造函数共性的东西定义在构造代码块中。

静态初始化块:

作用:给类进行初始化。随着类的加载而执行,且只执行一次

与构造代码块的区别:

  • 构造代码块用于初始化对象,每创建一个对象就会被执行一次;静态代码块用于初始化类,随着类的加载而执行,不管创建几个对象,都只执行一次。
  • 静态代码块优先于构造代码块的执行
  • 都定义在类中,一个带static关键字,一个不带static

泛型和泛型擦除

泛型:参数化类型,指在定义一个类、接口或者方法时可以指定类型参数。

泛型擦除:是指Java中的泛型只在编译期有效,在运行期间会被删除。也就是说所有泛型参数在编译后都会被清除掉。

在编译器编译后,泛型的转换规则如下:

  • List、List 擦除后的类型为 List;
  • List[]、List[] 擦除后的类型为 List[];
  • List<? extends E>、List<? super E> 擦除后的类型为 List;
  • List<T extends Serialzable & Cloneable> 擦除后类型为 List。

JDK 设计模式

  1. 单例模式:java.lang Runtime 类使用饿汉式创建单例
  2. 工厂模式:java.util.concurrent ThreadFactory 接口使用工厂模式创建线程
  3. 代理模式:java.lang.reflect Proxy 类使用动态代理
  4. 迭代器模式:java.util Iterator 接口使用迭代器遍历集合容器
  5. 模板方法模式:java.util.concurrent.locks AQS 中的 tryAcquire, tryRelease, tryAcquireShared, tryReleaseShared 方法被公平锁和非公平锁重写
  6. 建造者模式:线程不安全的 StringBuilder 和线程安全的 StringBuffer
  7. 装饰器模式 + 适配器模式
    1. 装饰器模式:通过组合替代继承的方式在不改变原始类的情况下添加增强功能,例如 FilterInputStreamFilterOutputStream 用于增强 InputStreamOutputStream 的功能。
    2. 适配器模式:字符流对象和字节流对象的相互适配
This post is licensed under CC BY 4.0 by the author.

Java知识点记录博客

MySQL知识点汇总