Java面试问题

Java基础知识包括以下内容:

  1. Java语言基础:Java程序的基本结构、数据类型、运算符、流程控制语句等。
  2. 面向对象编程:类和对象、继承、多态、抽象类和接口等。
  3. 异常处理:Java中的异常处理机制,包括异常类、异常处理语句、try-catch-finally块等。
  4. Java集合框架:Java中常用的集合类库,如List、Set、Map等,以及它们的特点和使用方法。
  5. 多线程编程:Java中的线程模型、线程的创建和启动、线程同步、线程安全等。
  6. IO流:Java中的输入输出流,包括字节流和字符流,以及文件操作、网络操作等。
  7. 泛型:Java中的泛型机制,包括泛型类、泛型方法、通配符等。
  8. 反射:Java中的反射机制,包括Class类、反射获取类信息、动态创建对象等。
  9. 注解:Java中的注解机制,包括元注解、自定义注解、注解处理器等。

java语言基础

JDK、JRE、JVM

JDK(Java Development Kit):是 Java 开发工具包,是整个 Java 的核心,包括了 Java 运行环境 JRE、Java 工具和 Java 基础类库。
JRE( Java Runtime Environment):是 Java 的运行环境,包含 JVM 标准实现及 Java 核心类库。
JVM(Java Virtual Machine):是 Java 虚拟机,是整个 Java 实现跨平台的最核心的部分,能够运行以 Java 语言写作的软件程序。所有的 Java 程序会首先被编译为 .class 的类文件,这种类文件可以在虚拟机上执行。

JRE

JRE(Java Runtime Environment)是一个在计算机操作系统之上运行的软件层,它提供了特定 Java 程序运行所需的类库和其他资源。简单来说,JRE 是用于执行 Java 应用程序的平台。

JRE 包含了 Java 虚拟机(JVM)、Java 核心类库和支持文件等。Java 虚拟机是整个 Java 运行环境的核心组成部分,它负责解释和执行 Java 代码,并将其转换为计算机可以理解的指令。Java 核心类库则包含了大量常用的 Java 类、接口和方法,这些类库可以被 Java 应用程序调用,从而完成各种功能。

相对于 JDK(Java Development Kit)来说,JRE 更加适合那些只需要运行 Java 应用程序的用户。因为 JDK 包含了比 JRE 更多的开发工具和库,主要面向 Java 开发人员。但是需要注意的是,每个 JDK 都包含了兼容的 JRE,因为运行 Java 程序也是开发 Java 程序的一部分。

作用

JRE(Java Runtime Environment)在 Java 应用程序中扮演了一个非常重要的角色,它主要有以下几个作用和功能:

  1. 执行 Java 应用程序:JRE 为 Java 程序提供了一个运行时环境,它可以解释和执行 Java 代码,并将其转换为计算机可以理解的指令。此外,JRE 还提供了许多标准类库和支持文件,使得开发人员可以使用各种工具和函数来编写高效、可靠的 Java 应用程序。
  2. 跨平台性:Java 应用程序可以在任何支持 JRE 的平台上运行,因为 JRE 可以自适应地调整和优化应用程序的运行方式。这就意味着,Java 应用程序可以在 Windows、Linux、Mac 等操作系统上运行,而不需要对不同的操作系统进行定制或修改。这种跨平台性是 Java 最重要的特点之一。
  3. 安全性:JRE 在执行 Java 应用程序时,可以提供一系列的安全措施来保护系统的安全性。例如,JRE 可以对访问系统资源(如文件、网络等)的权限进行限制,防止恶意程序的攻击。此外,JRE 还内置了一些防止代码注入和篡改的机制,可以有效地防范安全威胁。

JavaSE类库

当我们编写 Java 应用程序时,经常需要使用一些标准库或第三方库来加快开发效率,同时还能够提供更加强大的功能。下面列举了一些 JavaSE 中常用的类库:

  1. java.lang:这个包是 Java 中最基础、最重要的类库,包含了所有的基础类型和对象。例如,String、Thread、Object 等都是在这个包中定义的。
  2. java.util:这个包提供了许多实用的工具类,例如集合框架、日期时间处理、随机数生成、正则表达式处理等。
  3. java.io:这个包提供了许多输入输出操作的类和接口,例如文件读写、流操作、网络通信等。
  4. java.net:这个包提供了许多网络编程相关的类和接口,例如 URL、Socket、ServerSocket 等。
  5. java.awt 和 javax.swing:这两个包提供了图形用户界面(GUI)的支持,例如窗口、按钮、标签、文本框等。
  6. java.sql:这个包提供了 JDBC 数据库编程的支持,可以通过它来连接、查询、更新数据库。

JRE调优

JRE 中提供了一些工具来进行性能调优分析和优化,这些工具可以帮助开发人员找出应用程序中存在的性能瓶颈和问题,从而进行优化和改进。下面是一些常用的工具:

  1. jps:这个工具可以列出当前系统中所有正在运行的 Java 进程的 ID 和名称,用于快速查看系统中运行的 Java 应用程序。
  2. jstat:这个工具可以监视 JVM 内存、垃圾回收、类加载等方面的统计信息,并以命令行的形式输出。通过 jstat 可以快速了解应用程序的内存使用情况和垃圾回收效率等,从而进行优化。
  3. jmap:这个工具可以生成当前 JVM 的堆转储文件,并以命令行的形式输出。通过这个工具可以查看应用程序的内存占用情况和对象实例分布等,有助于发现应用程序中的内存泄漏和重复对象等问题。
  4. jstack:这个工具可以生成当前 JVM 的线程转储文件,并以命令行的形式输出。通过这个工具可以查看应用程序中的线程状态和堆栈信息,有助于发现线程阻塞和死锁等问题。
  5. VisualVM:这个工具是一个集成化的性能分析工具,可以通过插件机制支持多种性能分析工具,例如 jprofiler、MAT 等。它可以图形化地展示应用程序的内存、CPU、线程等方面的性能情况,还可以进行堆内存分析和线程分析等操作。

JVM

当Java代码运行时,JVM会在内存中执行以下几个主要步骤:

  1. 类加载:JVM首先需要将程序中的class文件加载到内存中,并将其转换为可执行代码。类加载器根据类的类名从文件系统、网络或ZIP文件等地方获取类的二进制数据,并且将其缓存到内存中,然后根据这些二进制数据创建一个Class对象。
  2. 内存分配:JVM会为程序分配内存空间,包括堆区、栈区、方法区等。其中,堆区用于存储对象实例和数组对象,栈区用于存储基本数据类型和对象的引用,而方法区则用于存储类的元数据信息和常量池等。
  3. 字节码解释/编译:JVM会将类的字节码解释成机器指令,或者根据JIT编译技术将字节码编译成本地代码,以提高程序的执行速度。JVM会对代码进行优化和调整,以便更好地运行。
  4. 执行代码:JVM会按照程序的逻辑顺序执行代码,包括方法调用、异常处理、线程同步等操作。每个线程都有自己的栈帧,保存着当前方法的局部变量表、操作数栈、动态链接、返回地址等信息。JVM通过方法调用栈来管理线程的执行流程,根据方法调用栈上下文切换不同的线程执行。
  5. 垃圾回收:JVM会定期检查内存中的对象,并清除不再使用的对象,以避免内存泄漏和内存溢出等问题。JVM使用各种不同的垃圾回收算法和策略,包括标记-清除、复制、标记-整理等,以及一些高级技术,如分代回收等,来优化垃圾回收的性能和效率。

java类的加载机制

什么是类的加载

类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个 java.lang.Class对象,用来封装类在方法区内的数据结构。类的加载的最终产品是位于堆区中的 Class对象, Class对象封装了类在方法区内的数据结构,并且向Java程序员提供了访问方法区内的数据结构的接口。

类加载器并不需要等到某个类被“首次主动使用”时再加载它,JVM规范允许类加载器在预料某个类将要被使用时就预先加载它,如果在预先加载的过程中遇到了.class文件缺失或存在错误,类加载器必须在程序首次主动使用该类时才报告错误(LinkageError错误)如果这个类一直没有被程序主动使用,那么类加载器就不会报告错误

加载.class文件的方式

  • 从本地系统中直接加载
  • 通过网络下载.class文件
  • 从zip,jar等归档文件中加载.class文件
  • 从专有数据库中提取.class文件
  • 将Java源文件动态编译为.class文件

Java内存结构

Java内存模型(Java Memory Model,简称JMM)定义了Java虚拟机在运行Java程序时内存的组织方式,以及多线程之间共享变量的可见性、有序性和原子性的规则。理解Java内存模型是理解Java多线程编程的关键所在。

我个人的理解,Java内存模型实际上就是对计算机内存组织、访问和共享的抽象和约束。Java程序中的所有变量(基本类型、对象引用、对象等)都存在于内存中,JMM规定了多个线程访问共享变量时内存的可见性、有序性和原子性的规则,保证了多线程程序的正确性。

具体来说,Java内存模型规定了所有变量都存储在主内存中,每个线程都有自己的工作内存,线程需要将共享变量从主内存复制到自己的工作内存中才能执行操作。当线程修改了共享变量的值后,需要将修改后的结果刷新到主内存中,以便其他线程能够看到这个修改。同时,Java内存模型还规定了多线程访问共享变量的顺序,避免出现乱序执行的问题。

Java内存模型的目标是保证程序的正确性,尤其是对于多线程程序,通过合理的内存访问规则,确保程序的正确性和稳定性。通常,我们在编写多线程程序时需要遵循Java内存模型的相关规则,例如使用volatile关键字,synchronized关键字等,确保共享变量的可见性、有序性和原子性。

程序计数器(Program Counter Register):

程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。另外,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。

Java虚拟机栈

Java虚拟机栈(Java Virtual Machine Stacks): Java虚拟机栈是一块用于存储方法运行时的数据结构。Java虚拟机栈由一个个栈帧(Stack Frame)组成,每个栈帧存储一个方法的运行时数据,包括方法的参数、局部变量、返回值及运算时中间结果。

如果函数调用陷入无限循环的话,就会导致栈中被压入太多栈帧而占用太多空间,导致栈空间过深。当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。除了 StackOverFlowError 错误之外,如果 Java 虚拟机栈 容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出 OutOfMemoryError 异 常。

本地方法栈

本地方法栈(Native Method Stacks): 本地方法栈与虚拟机栈的作用类似,只不过本质上是为虚拟机调用Native方法服务的。

Java中有很多常用的Native方法,其中一些比较常见的包括:

  1. System.arraycopy():用于复制数组。
  2. Object.finalize():在对象即将被回收时被调用。
  3. System.load()和System.loadLibrary():用于加载本地库文件。
  4. System.currentTimeMillis():返回当前时间的毫秒数。
  5. FileInputStream.read() 和 FileOutputStream.write():用于读取和写入文件。
  6. Thread.start():用于启动一个线程。
  7. Thread.sleep():用于使当前线程休眠指定时间。
  8. Math.random():返回一个0到1之间的随机数。
  9. Runtime.exec():用于执行操作系统命令。
  10. String.getBytes():返回字符串的字节数组表示。

这些Native方法都是非常常见和重要的,在Java编程中经常使用。同时还有很多其他的Native方法,如用于网络编程的Socket类、用于图形显示的AWT和Swing类等等。这些Native方法都具有不同的功能和特点,开发者可以根据自己的需求进行选择和使用。

Java堆

Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。

Java 世界中“几乎”所有的对象都在堆中分配,但是,随着 JIT 编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。从 JDK 1.7 开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。

Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)。从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代;再细致一点有:Eden、Survivor、Old 等空间。进一步划分的目的是更好地回收内存,或者更快地分配内存。

堆这里最容易出现的就是 OutOfMemoryError 错误,并且出现这种错误之后的表现形式还会有几种,比如:

  1. java.lang.OutOfMemoryError: GC Overhead Limit Exceeded : 当 JVM 花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。
  2. java.lang.OutOfMemoryError: Java heap space :假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发此错误。(和配置的最大堆内存有关,且受制于物理内存大小。最大堆内存可通过-Xmx参数配置,若没有特别配置,将会使用默认值)
方法区

方法区(Method Area):方法区是用于存储虚拟机加载的类信息、常量、静态变量、JIT编译器编译后的代码等数据的内存区域。《Java 虚拟机规范》只是规定了有方法区这么个概念和它的作用,方法区到底要如何实现那就是虚拟机自己要考虑的事情了。也就是说,在不同的虚拟机实现上,方法区的实现是不同的。

当虚拟机要使用一个类时,它需要读取并解析 Class 文件获取相关信息,再将信息存入到方法区。方法区会存储已被虚拟机加载的 类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据。在某些情况下,方法区可能会出现OOM(Out Of Memory)或者stackoverflow异常。其中,OOM异常通常是由于方法区中存储的类信息过多而导致的。例如,如果一个应用程序使用了大量的动态代理,那么它就可能会在方法区内创建大量的代理类,从而导致方法区内存溢出。而stackoverflow异常则通常是由于虚拟机调用栈过深而导致的,这个和方法区有关系不大。

方法区也需要GC,在大量使用反射、动态代理、CGLib 等字节码框架,动态生成 JSP 以及 OSGi 这类 频繁自定义类加载器的场景中,通常都需要 Java 虚拟机具备类型卸载的能力,以保证不 会对方法区造成过大的内存压力。

Metaspace

Metaspace的优点在于可以避免方法区所面临的一些问题,例如OOM(Out Of Memory)异常和内存泄漏等。由于Metaspace使用本地内存,因此其大小并不受Java堆内存大小的限制,我们可以通过设置JVM参数来控制Metaspace的大小。另外,Metaspace不需要进行Full GC(Full Garbage Collection),这也有助于提高应用程序的性能和稳定性。

Java 8及以上版本中的Metaspace采用了新的元数据回收方式,即使用CMS(Concurrent Mark-Sweep)算法来回收无用的类元数据。与传统的垃圾回收算法不同,CMS算法是一种基于标记-清除的算法,并且可以和应用程序线程同时工作,因此可以避免Full GC带来的停顿时间。

需要注意的是,尽管Metaspace不需要进行Full GC,但它仍然需要进行垃圾回收。虚拟机会在Metaspace中存储一个对象的计数器,当该对象被回收时,计数器会减1。当计数器变成0时,虚拟机会将该对象所占用的Metaspace空间释放出来,以便重新利用。如果Metaspace中的空间不足,或者没有足够的内存来满足对象的动态创建和卸载操作,那么Metaspace就会发生OOM异常。

JVM外内存

堆外内存(Off-Heap Memory),它不属于JVM内存区域的范畴,也不由JVM进行管理。堆外内存一般由操作系统直接分配和回收,并且在Java程序中使用时需要手动申请、管理和释放。

使用堆外内存的主要原因是避免Java堆大小限制对程序性能产生影响,避免了在 Java 堆和 Native 堆中来回复制数据,特别是处理大量数据时。此外,在某些场景下使用堆外内存还可以减少垃圾回收(GC)的频率,提高应用的稳定性。在Java中,可以通过Java NIO(New I/O)包提供的ByteBuffer来访问堆外内存,具体调用方式如下:

//分配堆外内存空间,size是分配的空间大小,单位为字节。
ByteBuffer buffer = ByteBuffer.allocateDirect(size);
//写入堆外内存数据,data是要写入的数据,类型为byte数组或byte缓冲区。
buffer.put(data);
//读取堆外内存数据,data是读取到的数据存放的byte数组或byte缓冲区。
buffer.get(data);
//释放堆外内存空间
buffer.clear();

上述代码中,clear()方法并不是真正释放内存空间的方法,而是将缓冲区的position、limit和mark位置重置,并不会清除缓冲区中的数据。如果需要真正释放内存空间,可以调用Buffer的cleaner()方法获取Cleaner对象,在程序退出时手动调用Cleaner的clean()方法释放内存空间。

需要注意的是,由于堆外内存不受JVM管理,因此在使用过程中需要特别小心,避免出现内存泄漏等问题。

CodeCache

Code Cache是Java虚拟机中的一块内存空间,是用于存储已编译的本地代码(native code)的区域,即被JIT编译器编译后的代码。当程序运行时,Java虚拟机会将运行较多的字节码(bytecode)JIT编译为机器码,然后将这些机器码存储在Code Cache中。

Java虚拟机的JIT编译器(Just-in-Time Compiler)在编译字节码为机器码时需要频繁地创建临时对象、代码缓存和跳转表等数据结构,这会给Java堆和方法区等内存带来一定的压力。而Code Cache是一块专门为JIT编译器分配的内存空间,可以作为缓存区域,减轻JIT编译器对Java堆和方法区等内存的压力,从而提高JIT编译器的性能。

Code Cache是Java虚拟机中的一个重要的内存区域,它的大小会影响程序性能。如果Code Cache过小会导致JIT编译器频繁地进行编译,增加程序运行的开销;而如果Code Cache过大则会浪费内存空间。根据应用程序的需求和实际情况,可以通过虚拟机参数来调整Code Cache的大小。

JIT编译器和性能调优

JIT(Just-in-Time)编译器是一种将字节码转换为本地机器码的实时编译器,可以显著提高 Java 程序的性能。与传统的 AOT(Ahead-of-Time)编译器不同,JIT 编译器利用程序运行时的信息来进行编译和代码优化,从而提高了代码执行的效率和速度。在 Java 虚拟机中,JIT 编译器通常会将热点代码编译成本地机器码,而将冷门代码仍然保留为解释执行的形式。

性能调优是一种基于 JMM(Java 内存模型)和 JIT 编译器的调试技术,旨在通过针对程序性能瓶颈进行优化,以提高程序的性能和可扩展性。具体来说,性能调优主要包括以下几个方面:

  1. 避免过度创建对象,尽量复用已有对象,减少 GC(Garbage Collection)的开销。
  2. 针对程序中的热点代码进行优化,将其优化为本地机器码,以提高执行效率。
  3. 使用低开销的算法和数据结构,减少运算和内存消耗。
  4. 避免使用过多的同步和锁操作,以避免线程竞争和死锁等问题。
  5. 对 I/O 操作进行优化,使用异步和缓存技术,减少阻塞和等待的时间。

垃圾回收

Java使用垃圾回收机制(Garbage Collection, GC)自动管理内存,以下是判断Java中的垃圾的标准:

  1. 引用计数法:Java中不使用引用计数法判断垃圾的原因是引用计数法难以解决循环引用问题,在循环引用的情况下,对象之间的引用计数永远不为0,导致无法回收垃圾。

  2. 可达性分析法:Java中采用可达性分析法判断垃圾。通过GC Roots对象检查某个对象是否可达。GC Roots对象包括当前正在执行的方法里的局部变量和输入参数、活动线程、所有Java类的静态字段,以及JNI引用等。如果一个对象不可达,也就是说没有任何一条引用链能够引用到这个对象,那么这个对象就是垃圾对象,可以被回收。

    从一组根对象开始,比如可以是虚拟机栈中的引用对象、方法区中的静态引用对象、常量引用等。搜索根对象所指向的对象,并标记它们为“存活”。继续搜索新标记的所有存活对象所指向的对象,并标记它们为“存活”,同时把它们加入待处理队列。重复步骤3,直到待处理队列为空,搜索结束。最后,所有未标记的对象都被认为是“死亡”对象,它们占用的内存可以被回收。在 Java 中,以下几种对象可以被视为 GC Roots:

    1. 虚拟机栈中引用的对象:即当前正在执行线程的方法中使用的本地变量和输入参数。
    2. 方法区中类静态属性引用的对象:即那些被 static 修饰的变量。
    3. 方法区中常量引用的对象:包括字符串常量池中的字符串对象,以及被 final 修饰的静态变量等。
    4. 本地方法栈中 JNI 引用的对象:即在 JNI 方法中使用的本地指针。

    这些对象会被虚拟机特别标记,并作为可达性分析的起点。垃圾回收器通过这些对象来保证对任何一个活动对象的扫描必定能够找到一个 GC Roots 的引用链,因此任何一个对象如果到 GC Roots 没有任何引用链相连,都被认为是不可用的对象,即垃圾对象,可以被回收。

即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们 暂时还处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对 象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标 记,随后进行一次筛选,筛选的条件是此对象是否有必要执行 finalize()方法。假如对象 没有覆盖 finalize()方法,或者 finalize()方法已经被虚拟机调用过,那么虚拟机将这两种 情况都视为“没有必要执行”。

任何一个对象的 finalize()方法都只会被系统自动 调用一次,如果对象面临下一次回收,它的 finalize()方法不会被再次执行。

垃圾收集器

  1. Serial收集器:Serial收集器是最古老的垃圾收集器,是一款单线程的收集器。在进行垃圾收集时,Serial收集器会暂停所有的工作线程,只使用一个线程来进行垃圾收集,因此会导致应用程序在某些时候产生可观的停顿。对于单核处理器或处理器核心数较少的环境 来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单 线程收集效率。客户端模式下的默认新生代收集器。
  2. Parallel收集器:Parallel收集器是Serial的并行版本,可以使用多个线程进行垃圾收集。Parallel收集器适合用在多核服务器上,在进行垃圾收集时可以并行来提高垃圾收集的效率。
  3. CMS收集器:**收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。**CMS收集器属于并发标记清除收集器,相比于Serial和Parallel收集器,它可以并发地回收垃圾,减少应用的停顿时间。CMS收集器以空间换时间的方式,通过空闲列表来避免标记和清除时的线程瓶颈。
  4. G1收集器:**是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征.**在选择要回收的垃圾时,G1收集器也是优先回收垃圾最大且最容易获取的区域。G1收集器将整个Java堆划分成若干个Region,每个Region大小通常为1MB或者512KB,同时在热点区域标记时间超过一定阈值时,G1收集器会优先对热点区域进行收集。

以上列举了其中几种常见的垃圾收集器,不同的垃圾收集器有不同的特点和应用场景,选用不同的垃圾收集器可以根据内存大小、多核CPU使用情况等进行选择,提高应用程序的性能。

CMS

在 CMS(Concurrent Mark Sweep)垃圾回收器中,GC 停顿时间被作为一个重要的指标来考虑。为了尽可能地减少 GC 停顿时间,CMS 垃圾回收器采用了并发标记清除算法。但是,由于在标记和清除阶段中需要修改对象引用,因此必须在执行这些操作时暂停应用程序线程。

具体来说,在 CMS 垃圾回收器中,当执行下列操作时,都会触发 GC 停顿:

  1. 初始标记 (Initial Mark):在该阶段中,CMS 将遍历根对象,并标记存活的对象。这个阶段需要暂停应用程序线程,通常只需要很短的时间。
  2. 并发标记 (Concurrent Mark):并发标记阶段就是从 GC Roots 的 直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线 程,可以与垃圾收集线程一起并发运行
  3. 重新标记 (Remark):而重新标记阶段则是为了修正并发标记期间, 因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。这个阶段需要暂停应用程序线程,通常只需要很短的时间。
  4. 并发清除 (Concurrent Sweep):在该阶段中,CMS 清除那些未被标记的对象。这个阶段不需要暂停应用程序线程,可以与应用程序并发执行。

缺点:首先,CMS 收集器对处理器资源非常敏感。事实上,面向并发设计的程序都对处 理器资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一 部分线程(或者说处理器的计算能力)而导致应用程序变慢,降低总吞吐量。

其次,CMS 收集器无法处理“浮动垃圾”(Floating Garbage),有可能出现 “Con-current Mode Failure”失败进而导致另一次完全“Stop The World”的 Full GC 的产 生。在 CMS 的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自 然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以 后,CMS 无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。同样也是由于在垃圾收集阶段用户线程还需要持续运行, 那就还需要预留足够内存空间提供给用户线程使用,因此 CMS 收集器不能像其他收集 器那样等待到老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时 的程序运作使用。

最后,CMS 是一款基于“标记-清除”算法实现 的收集器,如果读者对前面这部分介绍还有印象的话,就可能想到这意味着收集结束时 会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出 现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不 得不提前触发一次 Full GC 的情况。

G1

G1 不再坚持固定大小以及固定数量的分代区域划分,而是把连续的 Java 堆划分为多个大小 相等的独立区域(Region),每一个 Region 都可以根据需要,扮演新生代的 Eden 空间、 Survivor 空间,或者老年代空间。收集器能够对扮演不同角色的 Region 采用不同的策略 去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都 能获取很好的收集效果

  1. 初始标记(Initial Marking):仅仅只是标记一下 GC Roots 能直接关联到的对象, 并且修改 TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的 Region 中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行 Minor GC 的时候同步完成的,所以 G1 收集器在这个阶段实际并没有额外的停顿。
  2. 并发标记(Concurrent Marking):从 GC Root 开始对堆中对象进行可达性分析,递 归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发 执行。当对象图扫描完成以后,还要重新处理 SATB 记录下的在并发时有引用变动的对 象。
  3. 最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段 结束后仍遗留下来的最后那少量的 SATB 记录。
  4. 筛选回收(Live Data Counting and Evacuation):负责更新 Region 的统计数据,对 各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计 划,可以自由选择任意多个 Region 构成回收集,然后把决定回收的那一部分 Region 的 存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。这里的操作涉及 存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。

缺点:用户程序运行过程中,G1 无论是为了垃圾收集产生的内存占用(Footprint) 还是程序运行时的额外执行负载(Overload)都要比 CMS 要高。虽然 G1 和 CMS 都使用卡表来处理跨代指针,但 G1 的卡表实现 更为复杂,而且堆中每个 Region,无论扮演的是新生代还是老年代角色,都必须有一份 卡表,这导致 G1 的记忆集(和其他内存消耗)可能会占整个堆容量的 20%乃至更多的 内存空间

Full GC和Minor GC(GC时机)

Full GC(full garbage collection)指的是对Java虚拟机中所有的内存进行垃圾回收,包括新生代、老年代和永久代。正常情况下,Full GC的触发机制通常有以下几种情况:

  1. 调用System.gc()方法:调用此方法会请求JVM进行Full GC,但是否真正执行Full GC取决于具体的JVM实现。
  2. 空间分配担保失败:在进行一次Minor GC之后,如果老年代空间无法满足新生代分配的空间,此时会发生一次Full GC。
  3. 大对象直接进入老年代:当一个对象的大小超过新生代的大小,且该对象被分配在新生代时,JVM会将其直接分配到老年代中,以避免频繁地Minor GC。
  4. 持久代空间不足:当持久代中的空间已经被占用完毕,而无法再为新的类元数据分配空间时,会触发Full GC。
  5. CMS GC的Concurrent Mode Failure:使用CMS垃圾回收器时,如果在执行垃圾回收过程中出现了当前应用程序线程数大于垃圾回收器线程数的情况,那么就会出现Concurrent Mode Failure,此时JVM会自动触发Full GC来回收堆内存。

需要注意的是,Full GC会停止应用进程的所有线程,因此它的性能比Minor GC要差很多,而且可能会导致应用程序出现较长时间的停顿甚至暂停。因此,在系统运行期间尽可能地减少Full GC的次数是非常重要的,可以通过合理地调整JVM参数、优化代码实现等方式来达到这个目标。

一般情况下,Minor GC的触发机制有以下几种:

  1. 空间不足:当新生代中的内存空间占满时,就需要进行Minor GC来释放无用对象的内存,以便为新的对象腾出空间。
  2. 对象的年龄达到阈值:当新生代中的某个对象经过多次Minor GC仍然存活,并且年龄达到了一定的阈值时,就需要将这个对象转移到老年代中,此时也需要进行一次Minor GC。

需要注意的是,Minor GC期间只会对新生代中的对象进行垃圾回收,因此它的执行速度通常比Full GC要快得多。另外,由于新生代中的大部分对象都是生命周期很短的,因此Minor GC会频繁地触发,但它所占用的时间一般都很短,不会对应用程序的响应速度产生较大影响。

此外:

  • System.gc():调用该方法会通知虚拟机执行一次垃圾回收,但具体执行时间取决于虚拟机的实现。
  • OOM(Out of Memory):当Java堆空间不足时将抛出该异常,此时将触发GC进行垃圾清理。

避免 Full GC

  1. 合理设置堆空间大小。如果堆空间设置得太小,可能会导致频繁执行 GC,甚至出现 Full GC 的情况;如果堆空间设置过大,也会增加 GC 的时间和频率。所以我们需要根据应用的实际情况来设置合适的堆空间大小。
  2. 尽量减少对象的创建,在代码中尽量使用复用对象的方式代替频繁创建新的对象。因为对象的创建和销毁都会占用内存空间,增加 GC 的频率。
  3. 对于一些无法复用或者经常使用的对象,可以使用对象池来管理。这样可以避免频繁地创建和销毁对象,减少 GC 的次数。
  4. 优化程序代码,减少无意义的计算和操作。这样可以减少 CPU 的占用率,减轻 GC 的压力。
  5. 选用合适的 GC 算法。根据应用的实际情况,选择适合的垃圾回收器和 GC 策略,如 CMS、G1 等,可以提高 GC 的效率和稳定性,减少 Full GC 的发生。
  6. 避免持有过长时间的数据结构,尤其是占内存较大的数据结构,如集合、Map 等。如果需要缓存数据,可以使用软引用或弱引用等机制,以便在内存不足时释放这些数据。

年轻代对象晋升

在JVM中,内存分为新生代和老年代两部分。Eden 区、两个 Survivor 区 S0 和 S1 都属于新生代

新生代是指用于存储新创建的对象的内存区域,其中包括 Eden空间和两个Survivor空间。当一个对象被创建时,它会被放入Eden空间中。在进行Minor GC时,所有在Eden空间中仍然存活的对象都会被复制到一个Survivor空间中,而在上一次的Minor GC中仍然存活的对象也会被复制到另外一个Survivor空间中。这样,在下一次Minor GC时,只有在前一个Survivor空间与Eden空间之间的对象会被清除掉,而另外一个Survivor空间中的对象则会被复制到这个空间的另一端。

老年代是指用于存储已经存活了一段时间的对象的内存区域。当一个对象在新生代中经过多次垃圾回收后仍然存活,就会被晋升到老年代中。在进行Full GC时,老年代中所有无法被回收的对象都会被清除掉。

对象晋升指的是将新生代(Young Generation)中存活时间较长、年龄较大的对象移动到老年代(Old Generation)中的过程。在JVM中,当一个对象经过多次垃圾回收后仍然存活,就会被晋升到老年代,以便为更大、更稳定的内存提供更长时间的存储保障。

对象晋升也不是完全无害的,过早晋升会导致以下危害:

  1. 内存使用不均衡:将对象过早地晋升到老年代,会导致老年代的内存使用不均衡。由于老年代空间有限,如果过早地将对象晋升到老年代,可能会导致老年代的内存被占满,而新生代的内存得不到充分利用。
  2. 内存碎片化:在并发情况下,如果对象过早地晋升到老年代,会导致老年代中出现大量的不连续的内存碎片,这些碎片不能再分配给新生成的对象。因此,如果某个对象的大小大于老年代中最大的连续空间,那么这个对象就不能被放入老年代中了,这可以触发 Full GC,从而影响系统的性能。
  3. 延长垃圾回收时间:当对象晋升到老年代时,由于老年代是需要整块清理的,因此它的垃圾回收时间往往比新生代更长。如果晋升的对象比较多,那么就会导致垃圾回收时间变长,从而影响系统的性能。

因此,在实际开发中,我们需要根据实际情况来设置对象晋升的阈值,以充分利用各个内存区域的空间,避免过早晋升所带来的问题。将内存分为新生代和老年代的主要目的是为了充分利用不同内存区域的特点,以达到更好的垃圾回收效果。由于大部分对象在其生命周期中很快就会消失,因此将它们存储在较小的新生代中可以使得垃圾回收更加高效;而将已经存活了一段时间的对象存储在较大的老年代中可以减少垃圾回收的频率,从而提高系统的性能。

堆和栈

  • 堆(Heap),此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。这一点在 Java 虚拟机规范中的描述是:所有的对象实例以及数组都要在堆上分配。在程序运行时,当我们使用关键字new创建一个对象时,它就会被分配在堆中,并返回该对象的引用(Reference)。堆是由JVM自动进行内存分配和回收的,无需手动管理,这也是Java语言最大的优势之一。堆的缺点是读写速度相对较慢,同时由于需要在运行时动态分配内存,因此可能存在内存碎片的问题。

  • 通常所说的栈(Stack),是指虚拟机栈。虚拟机栈用于存储局部变量等。局部变量表存放了编译期可知长度的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不等同于对象本身,是对象在堆内存的首地址)。方法执行完,自动释放。

    是Java虚拟机运行时的另一块内存区域,用来存储线程执行方法时的局部变量表、操作数栈、动态链接、方法出口等信息。在程序运行时,每条线程都会有自己的栈空间,栈中主要保存基本类型变量和对象的引用,而非对象本身。栈的容量较小,速度快,并且栈中的数据是线程私有的,不会发生共享和冲突。但是,由于栈的大小是在编译时就确定了的,因此它不够灵活。当一个线程的栈空间不足时,就会抛出栈溢出异常(StackOverflowError)。

  • 方法区(MethodArea),用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。方法区是所有线程共享的内存区域,与Java堆一样,也是由JVM自动进行内存分配和回收的。与堆区不同的是,方法区中存储的对象不需要垃圾回收,因为这些对象的生命周期与程序的生命周期相同。

    方法区的优点是可以实现共享,不同的实例可以共同使用同一个类信息和常量,避免了重复加载和浪费内存的问题。同时,方法区中存储的数据都是属于静态的、只读的,不存在并发修改的问题。但是,方法区也存在一些缺点。首先,永久代的大小固定,无法动态调整。其次,方法区容易出现内存泄露,尤其是在大量使用动态代理、反射等技术时容易出现。此外,由于class文件的数量可能很大,方法区的负载较重,会占用较多的内存资源。

从两方面来考虑,堆和栈各有优劣:

  1. 分配和释放 堆在分配和释放内存时都需要调用函数(如MALLOC和FREE),会花费一定的时间。因为堆会产生空洞,多次申请和释放可能导致内存碎片,增加了堆内存的申请和释放时间。而栈不需要进行这些额外的工作。
  2. 存储寻址 栈的物理地址空间是连续的,访问栈的数据只需一次访问内存,速度较快。而堆的物理地址空间不一定是连续的,访问堆的数据需要两次访问内存,第一次得取得指针,第二次才能获取数据,速度相对较慢。

综上所述,从速度上来看,栈比堆快。但现代化的内存分配器通过类似slab allocator的设计已经尽量令相关数据尽可能地放在一起,从CPU数据缓存角度,绝大多数程序并不需要在栈上分配内存,而在堆上分配缓冲区则可以避免栈缓冲区溢出的问题。因此,在实际编程中,需要根据程序的要求和实际情况选择合适的内存分配方式。

Java程序的基本结构

  1. 包声明:Java程序的第一行必须是包声明语句,用于标识类所在的包。
  2. 引入类:可选的导入其他类的语句,用于引入类中所需的其他类。
  3. 类声明:Java程序中的所有代码必须定义在类中,因此需要使用类声明语句定义类。
  4. main()方法:Java程序必须包含一个public static void main(String[] args)方法,这个方法是程序的入口点。
  5. 语句块:Java程序由一条条语句组成,多条语句可以组成一个语句块。

Java数据类型

  1. 基本数据类型(Primitive Data Types):包括布尔类型(boolean)、字符类型(char)、整型(byte、short、int、long)、浮点类型(float、double)等。
  2. 引用数据类型(Reference Data Types):包括类类型(Class)、接口类型(Interface)、数组类型(Array)等。
  3. 空类型(Void Type):表示没有值的类型,只能用于方法的返回值类型。
  4. 自定义类型(Custom Data Types):根据具体需要定义的数据类型,可以通过面向对象编程方式来创建。

String不可变

在Java中,String是不可变的,也就是说,一旦创建了一个String对象,它的值就无法被改变。这个特性是通过以下两种方法来保证的:

  1. 使用final关键字对字符数组value进行修饰,使得在创建String对象时,其内部的字符数组无法被修改;
  2. 在String对象的方法中,对任何可能导致字符串值改变的操作都进行了限制。例如,通过substring()方法获取子串时,并没有新建一个字符数组存储子串,而是在原始字符串的基础上创建了一个新的String对象。

因为String是不可变的,所以它具有以下优点:

  1. 线程安全:在多线程环境下,由于String是不可变的,所以可以避免因为竞争而导致的数据不一致问题。
  2. 安全性:因为字符串的值不能被修改,所以可以避免一些安全漏洞,例如SQL注入、加解密等。
  3. 可哈希性:由于String对象的值不变,所以可以作为Map中的键值、Set中的元素等需要哈希的数据结构中使用。

需要注意的是,虽然String本身是不可变的,但是通过反射机制,仍然可以修改其内部的字符数组。因此,在涉及到安全和并发性时,应该避免使用反射对String进行操作。

另外,由于String不可变,因此在进行字符串拼接等操作时,如果使用+、+=等操作符,会创建很多临时对象,从而引发大量的内存开销。因此,建议使用StringBuilder或StringBuffer等可变的字符串类来完成字符串拼接操作。

String、StringBuilder和StringBuffer

String、StringBuilder和StringBuffer都是Java中用来存储字符串的类,它们之间的区别主要在以下几个方面:

  1. 可变性
    • String是不可变的对象,在每次对其进行改变时,都会生成一个新的String对象,然后将指针指向新对象,所以经常改变内容的字符串最好使用StringBuilder或StringBuffer。
    • StringBuilder和StringBuffer都是可变的对象,可以在内部修改其值,且不会生成新的对象。
  2. 线程安全性
    • String是不可变的对象,因此线程安全。
    • StringBuffer是线程安全的,因为它的所有公共方法都是同步的,但这也导致了它的效率较低。
    • StringBuilder不是线程安全的,因为它的方法没有加同步锁,但它的效率较高。

综上所述,如果需要频繁修改字符串且要求线程安全,可以使用StringBuffer;如果只是单线程下频繁修改字符串,则使用StringBuilder效率更高;如果只是想存储一个字符串常量,建议使用String。

String::intern()

String::intern()是一个本地方法,它的作用是如果字符串常量池中已经包含一个等于 此 String 对象的字符串,则返回代表池中这个字符串的 String 对象的引用;否则,会将 此 String 对象包含的字符串添加到常量池中,并且返回此 String 对象的引用。

integer值的缓存

Java中的Integer类提供了一个静态内部类IntegerCache来缓存整数对象,该缓存会在自动装箱时被使用。具体来说,Java缓存了-128到127之间的所有整数对象,这些整数对象在虚拟机启动时就被创建并缓存起来了。

当我们需要创建一个Integer对象时,如果它的值在缓存范围内,那么将直接从缓存中获取对应的整数对象;否则,才会新建一个Integer对象。这样可以避免因为大量的自动装箱而频繁地创建和销毁Integer对象,从而提高内存使用效率。

需要注意的是,这个缓存机制只适用于自动装箱,对于手动创建的Integer对象并不适用。同时,由于缓存范围仅限于-128到127之间,如果超出这个范围,依然需要新建一个Integer对象。因此,在编写Java程序时,应该合理使用Integer缓存机制,避免因为频繁创建和销毁对象而浪费内存资源。

值类型与引用类型

在 Java 中类型可分为两大类:值类型与引用类型。值类型就是基本数据类型(如 int、double 等),而引用类型是指除了基本的变量类型之外的所有类型(如通过 class 定义的类型)。所有的类型在内存中都会分配一定的存储空间(形参在使用的时候也会分配存储空间,方法调用完成之后,这块存储空间自动消失),基本的变量类型只有一块存储空间(分配在stack中),而引用类型有两块存储空间(一块在 stack 中,一块在 heap 中)。

值类型(基本数据类型)

  • 整数3种:short int long 占用内存大小分别是 2个字节 4个字节 8个字节
  • 浮点数2种:float double 占用内存大小分别是 4个字节 8个字节
  • 字节1种:byte 占用内存大小1个字节
  • 字符1种:char 占用内存大小2个字节
  • 布尔1种:boolean 占用内存大小没有明确的规定,它依赖于 JVM 厂商的具体实现。逻辑上理解是占用 1 位,但是实际中会考虑计算机高效存储因素。

另外,Java 的每种基本类型所占存储空间的大小不会像其他大多数语言那样随机器硬件架构的变化而变化。这种所占存储空间大小的不变性是 Java 程序比用其他大多数语言编写的程序更具可移植性的原因之一

有时候为了防止有默认值的出现,会用基本类型的包装类型(引用类型)。这样就会在栈内存存放数据的引用(地址),在堆内存存放具体的值。

  • int <–> Integer
  • char <–> Character

装箱和拆箱(运行时)

基本类型都有对应的包装类型,基本类型与其对应的包装类型之间的赋值使用自动装箱与拆箱完成。

装箱:基本类型转为包装类型
拆箱:包装类型转为基本类型

编译器会在缓冲池范围内的基本类型自动装箱过程调用 valueOf() 方法,因此多个 Integer 实例使用自动装箱来创建并且值相同,那么就会引用相同的对象。

**装箱拆箱是一个运行时的过程。**在Java 5之前,我们需要手动进行装箱和拆箱。在Java 5及以后版本中,Java引入了自动装箱和自动拆箱机制,这样就可以更加方便地进行基本类型和对应封装类之间的转换。

这种机制是在编译阶段通过自动插入代码实现的,例如,当我们使用int类型参数调用一个需要Integer类型参数的方法时,编译器会自动将int类型的值自动装箱为Integer类型的对象。

但是,这个过程是在运行时进行的,也就是说,实际上整个程序被编译成字节码之后,只有在运行时才能执行自动装箱和拆箱的操作,因此会带来一定的运行时开销。需要注意的是,在进行大量的自动装箱和拆箱操作时,由于频繁地创建和销毁对象会带来一定的性能问题,因此应该尽量避免不必要的装箱和拆箱,尽可能地直接使用基本数据类型。

引用类型

引用其实就像是一个对象的名字或者别名 (alias),一个对象在内存中会请求一块空间来保存数据,根据对象的大小,它可能需要占用的空间大小也不等。访问对象的时候,我们不会直接是访问对象在内存中的数据,而是通过引用去访问

引用也是一种数据类型,我们可以把它想象为类似 C++ 语言中指针的东西,它指示了对象在内存中的地址——只不过我们不能够观察到这个地址究竟是什么。如果我们定义了不止一个引用指向同一个对象,那么这些引用是不相同的,因为引用也是一种数据类型,需要一定的内存空间(stack,栈空间)来保存。但是它们的值是相同的,都指示同一个对象在内存(heap,堆空间)的中位置。

引用与对象

在 Java 中一切都被视为了对象,但是我们操作的标识符实际上是对象的一个引用(reference)。

  1. 创建一个引用,引用可以独立存在,并不一定需要与一个对象关联。(String s)
  2. 通过将这个叫“引用”的标识符指向某个对象,之后便可以通过这个引用来实现操作对象了。(s = new String(“abc”))

四种引用类型

强引用(Strong Reference):Java 中默认声明的就是强引用。只要强引用存在,垃圾回收器将永远不会回收被引用的对象,哪怕内存不足时,JVM也会直接抛出 OutOfMemoryError,不会去回收。如果想中断强引用与对象之间的联系,可以显示的将强引用赋值为 null,这样一来,JVM 就可以适时的回收对象了。

  • 软引用(Soft Reference):软引用是用来描述一些非必需但仍有用的对象。在内存足够的时候,软引用对象不会被回收,只有在内存不足时,系统则会回收软引用对象,如果回收了软引用对象之后仍然没有足够的内存,才会抛出内存溢出异常。这种特性常常被用来实现内存敏感的高速缓存,比如网页缓存,图片缓存等。只要垃圾回收器没有回收它,这个对象就能被程序使用。

    SoftReference softReference = new SoftReference(student);

  • 弱引用(Weak Reference):弱引用的引用强度比软引用要更弱一些,无论内存是否足够,只要 JVM 开始进行垃圾回收,那些被弱引用关联的对象都会被回收。用 java.lang.ref.WeakReference 来表示弱引用。

  • 虚引用(Phantom Reference):

  • 虚引用是最弱的一种引用关系,如果一个对象仅持有虚引用,那么它就和没有任何引用一样,它随时可能会被回收。设置虚引用的目的是为了被虚引用关联的对象在被垃圾回收器回收的时候,能够收到一个系统通知(用来跟踪对象被GC回收的活动)。无法通过虚引用来获取对象。

深拷贝和浅拷贝

在Java中,深拷贝和浅拷贝是针对对象进行数据复制的方式而言的,二者的区别在于赋值操作时是否会创建一个新的对象。

浅拷贝:创建一个新对象,该对象与原对象共享引用类型的成员变量。也就是说,浅拷贝只是将原对象的引用复制给了新对象,而不是将所有属性都复制一份。

深拷贝:创建一个新对象,该对象中的引用类型成员变量也将复制一份到新对象中。也就是说,深拷贝会在堆内存中新建一个独立的对象,并且将原对象中的所有属性以及引用类型的属性都复制到新对象中,这样新对象的修改不会影响原对象。

Java中提供了两种实现深拷贝的方式:

1.序列化和反序列化:将对象序列化为二进制流,再通过反序列化生成一个新对象。

2.递归拷贝:通过递归遍历对象,将对象的每个属性都复制一份。

Java 集合

容器主要包括 Collection 和 Map 两种,Collection 存储着对象的集合,而 Map 存储着键值对(两个对象)的映射表。

Collection集合

  • Set
    • TreeSet 基于红黑树实现,支持有序性操作,例如根据一个范围查找元素的操作。但是查找效率不如 HashSet,HashSet 查找的时间复杂度为 O(1),TreeSet 则为 O(logN)。
    • HashSet 基于哈希表实现,支持快速查找,但不支持有序性操作。并且失去了元素的插入顺序信息,也就是说使用 Iterator 遍历 HashSet 得到的结果是不确定的。
    • LinkedHashSet 具有 HashSet 的查找效率,且内部使用双向链表维护元素的插入顺序。
  • List
    • ArrayList 基于动态数组实现,支持随机访问。
    • Vector 和 ArrayList 类似,但它是线程安全的。
    • LinkedList 基于双向链表实现,只能顺序访问,但是可以快速地在链表中间插入和删除元素。不仅如此,LinkedList 还可以用作栈、队列和双向队列。
  • Queue
    • LinkedList 可以用它来实现双向队列。
    • PriorityQueue 基于堆结构实现,可以用它来实现优先队列。

HashSet

在Java中,Set是一种无序不可重复的集合,常用的三种Set实现类是HashSet、LinkedHashSet、TreeSet。

  1. HashSet: HashSet是基于HashMap实现的,它使用哈希表来存储元素。因为哈希表具有很好的查找和插入性能,所以HashSet也具有很好的性能。HashSet不保证元素的迭代顺序,可以存储null元素。使用HashSet时,需要注意元素类型必须正确实现hashCode()和equals()方法。
  2. LinkedHashSet:LinkedHashSet在HashSet的基础上维护了一个双向链表来保存元素的插入顺序,因此可以按照元素插入的顺序进行迭代输出。LinkedHashSet的性能和HashSet相当,在遍历方面略有更优点。与HashSet一样,也可以存储null元素。
  3. TreeSet:TreeSet是基于红黑树的实现,它可以确保元素处于排序状态。TreeSet提供了自然排序和定制排序两种方式,前者是指元素按照自然顺序(即Comparable接口默认的顺序)排序,后者是指使用Comparator接口定义的顺序进行排序。TreeSet中的元素必须是可比较的,即元素类型必须实现Comparable接口或者在构造函数中提供一个Comparator对象。TreeSet不允许存储null元素。

三者的区别主要在于存储顺序、性能、可以存储的元素类型和是否有序。如果需要存储无序的元素集合,使用HashSet;如果需要按照插入顺序保存元素并且需要快速访问元素,使用LinkedHashSet;如果需要有序的元素集合,则使用TreeSet。 对于存储null值,HashSet和LinkedHashSet都可以存储,而TreeSet是不允许的。

红黑树

红黑树(Red-Black Tree)是一种自平衡二叉查找树,它是一种特殊的AVL树,用于实现关联数组的数据结构。红黑树的特点如下:

  1. 每个节点都有一个颜色属性,取值为红色或黑色。
  2. 根节点是黑色的。
  3. 叶子节点(NIL节点,即空节点)是黑色的。
  4. 如果一个节点是红色的,则它的子节点必须是黑色的。
  5. 从任意一个节点到其每个叶子节点的所有路径都包含相同数目的黑色节点。

通过这些特性,红黑树可以保证任何情况下的最长路径不超过最短路径的两倍,从而达到了平衡的目的。

红黑树在计算机科学中被广泛应用,主要用途如下:

  1. 数据库索引:红黑树在数据库中通常被用于创建索引,以加快检索速度。
  2. STL的实现:在C++标准库中,set和map等容器的底层就是使用红黑树进行实现。
  3. Java集合框架:Java中的TreeMap、TreeSet等集合类都是使用红黑树进行实现的。
  4. Linux进程调度:在Linux中,进程被分为五个优先级,由红黑树来维护各个优先级的进程队列。

红黑树是一种自平衡的数据结构,在维护平衡性的同时可以进行高效的查找、插入和删除操作。在实际应用中,红黑树有着广泛的用途,是一种经典的数据结构

set线程不安全

HashSet、LinkedHashSet和TreeSet都不是线程安全的,即它们不是线程同步的,并且不能同时由多个线程访问。如果多个线程同时访问HashSet、LinkedHashSet或TreeSet实例,可能会导致不可预期的结果,比如ConcurrentModificationException异常、数据不一致等问题。

为了保证线程安全,可以使用以下两种方法:

  1. Collections.synchronizedSet()方法:该方法返回一个线程安全的Set集合,可以在多线程环境下使用。不过需要注意的是,在对该集合进行遍历、迭代、删除时,需要手动加锁。
  2. 使用并发集合类:Java中提供了ConcurrentHashMap和ConcurrentSkipListSet等并发集合类,它们都是线程安全的。如果需要在多线程环境中使用Set,可以考虑使用ConcurrentSkipListSet。

重写hashCode()和equals()

当使用Collections类的集合(如HashSet、HashMap)时,需要注意到集合中的元素是通过hashCode()和equals()方法来进行比较的。默认情况下,这两个方法都是使用Object类中的实现,即比较对象的内存地址。如果在自定义类中没有重写这两个方法,可能会导致如下问题:

  1. 相同逻辑实体的对象被视为不同的对象,会影响查找和替换操作的正确性。比如,将一个自定义类的对象加入到HashSet中,如果没有重写hashCode()和equals()方法,这个对象就会被重复添加多次,影响集合的正确性。
  2. 如果使用自定义类作为HashMap的键,则必须重写hashCode()和equals()方法,以确保键的唯一性和正确性。否则,可能会出现相同逻辑实体的对象被视为不同的键的情况,导致查找和替换失败。

因此,在使用Collections类的集合时,我们需要重写自定义类中的hashCode()和equals()方法以确保集合中元素的正确性。其中,重写hashCode()方法能够保证相等的对象返回的哈希码相同,而重写equals()方法则能够保证在相等的情况下,哈希表中元素的比较正确。同时,在重写equals()方法时,需要注意自反性、对称性、传递性和一致性等规则。

Vector 和 ArrayList

Vector和ArrayList都是List接口的具体实现类,它们都可以用于存储一组有序的元素,并允许元素重复。下面我们来比较一下它们的不同之处。

  1. 线程安全性

    Vector是线程安全的,而ArrayList则不是。在多线程环境下对Vector进行操作时,会被自动加上锁,以保证线程安全;而对于ArrayList,在多线程环境下,需要手动控制同步,否则会出现不安全的情况。

  2. 扩容机制

    Vector和ArrayList的扩容机制也有所不同。在添加元素时,如果Vector的大小超过了当前容量,它的容量会自动加上一定的值(默认是原来的两倍);而ArrayList只有在元素个数超过当前容量时,才会增加容量。另外,ArrayList的默认初始容量为10,而Vector的默认初始容量为10,但是当我们创建一个指定大小的Vector对象时,其初始容量会根据指定的大小进行设置。

  3. 性能

    在性能方面,由于Vector是线程安全的,因此在多线程环境下开销较大;而ArrayList不是线程安全的,因此在单线程环境下,其性能比Vector要好。此外,由于ArrayList是基于数组实现的,因此在随机访问或者按索引遍历元素时,它的性能要优于Vector。

综上所述,Vector和ArrayList虽然都是List接口的实现类,但是它们的性质和使用场景有所不同。如果需要在多线程环境下使用,或者需要经常进行插入和删除操作,那么应该使用Vector;如果在单线程环境下使用,或者需要频繁进行随机访问和按索引遍历操作,那么就应该选择ArrayList。

ArrayList底层

ArrayList实现了List接口,是顺序容器,即元素存放的数据与放进去的顺序相同,允许放入null元素,底层通过数组实现。除该类未实现同步外,其余跟Vector大致相同。

每个ArrayList都有一个容量(capacity),表示底层数组的实际大小,容器内存储元素的个数不能多于当前容量。当向容器中添加元素时,如果容量不足,容器会自动增大底层数组的大小。前面已经提过,Java泛型只是编译器提供的语法糖,所以这里的数组是一个Object数组,以便能够容纳任何类型的对象。

ArrayList进行遍历时插入

在使用ArrayList进行遍历的过程中,是不能执行插入操作的。这是因为执行插入操作时,ArrayList的内部结构可能会发生变化,导致迭代器(iterator)所在的位置失效,引起ConcurrentModificationException异常。

如果需要在遍历ArrayList时执行插入操作,可以考虑使用ListIterator。ListIterator是一个专门用于访问列表元素的迭代器,它支持在任意位置插入、删除和替换列表中的元素。通过ListIterator,可以在遍历ArrayList的同时进行插入操作,而且不会抛出ConcurrentModificationException异常。

以下是一个使用ListIterator遍历并插入元素的示例代码:

java复制代码ArrayList<String> list = new ArrayList<>();
list.add("A");
list.add("B");
ListIterator<String> it = list.listIterator();
while (it.hasNext()) {
    String str = it.next();
    if (str.equals("B")) {
        it.add("C");
    }
}
System.out.println(list);

运行此代码,输出结果为:[A, B, C],可见在遍历ArrayList的同时成功地执行了插入操作。

ArrayList自动扩容

每当向数组中添加元素时,都要去检查添加后元素的个数是否会超出当前数组的长度,如果超出,数组将会进行扩容,以满足添加数据的需求。数组扩容通过ensureCapacity(int minCapacity)方法来实现。在实际添加大量元素前,我也可以使用ensureCapacity来手动增加ArrayList实例的容量,以减少递增式再分配的数量。

数组进行扩容时,会将老数组中的元素重新拷贝一份到新的数组中,每次数组容量的增长大约是其原容量的1.5倍。这种操作的代价是很高的,因此在实际使用时,我们应该尽量避免数组容量的扩张。当我们可预知要保存的元素的多少时,要在构造ArrayList实例时,就指定其容量,以避免数组扩容的发生。或者根据实际需求,通过调用ensureCapacity方法来手动增加ArrayList实例的容量。

ArrayList和LinkedList

  1. 底层数据结构:ArrayList底层的数据结构是数组,它可以随机访问每个元素,并通过数组的复制实现动态扩容和收缩。而LinkedList底层的数据结构是双向链表,每个元素都维护着前后两个元素的引用。
  2. 插入和删除操作:由于LinkedList底层为双向链表,因此当需要进行大量的插入和删除操作时,LinkedList的效率要高于ArrayList。ArrayList的插入和删除元素是比较麻烦的,因为需要移动其他元素的位置,因此效率相对较低,尤其是在较大的数组容量下。
  3. 容量和大小的改变:当需要对链表进行动态扩容时,LinkedList的操作比ArrayList相对性能更好。因为LinkedList底层数据结构为链表,而且不需要连续空间的存储,所以当需要动态扩容时,只需要改变前后两个元素的引用即可;而ArrayList需要将之前的元素复制到新的连续存储空间中。
  4. 随机访问:在进行随机访问时,ArrayList的效率要高于LinkedList。在ArrayList中,可以直接定位任意一处元素的位置;而在LinkedList中,需要从头结点或尾结点开始遍历链表,找到指定位置的元素。

Map

  • TreeMap 基于红黑树实现。
  • HashMap 1.7基于哈希表实现,1.8基于数组+链表+红黑树。
  • HashTable 和 HashMap 类似,但它是线程安全的,这意味着同一时刻多个线程可以同时写入 HashTable 并且不会导致数据不一致。它是遗留类,不应该去使用它。现在可以使用 ConcurrentHashMap 来支持线程安全,并且 ConcurrentHashMap 的效率会更高(1.7 ConcurrentHashMap 引入了分段锁, 1.8 引入了红黑树)。
  • LinkedHashMap 使用双向链表来维护元素的顺序,顺序为插入顺序或者最近最少使用(LRU)顺序。

HASHMAP(线程不安全)

HashMap是Java中常用的数据结构之一,它是以一个数组加链表存储的方式来实现键值对的映射的。具体而言, HashMap采用了哈希表的结构,也就是说它使用了散列表来实现。散列表是一个通过计算数组下标的方式,将一组数据映射到数组的不同位置上的技术。细节的实现过程如下:

  1. 创建HashMap实例时,会先申请一个默认大小为16的数组table。
  2. 然后向HashMap中添加键值对时,通过key的hashCode()方法得到一个对应的hash值;
  3. 为了将该hash值对应的键值对存放到table数组中,需要将hash值对table长度取模,得到在数组中的下标。
  4. 如果此下标处无数据,则直接将该键值对存入此处即可。
  5. 如果此下标处已经有数据,则会以链表的形式进行存储,将该键值对添加到链表的末尾。
  6. 如果链表长度超过了阈值(默认为8),则会把链表转化为红黑树,以提高查询效率。
  7. 当需要查询某个键值对时,通过该键值对的key的hashCode()方法得到hash值。
  8. 通过取模计算出在table数组中的下标,即可在该下标处的链表或红黑树中寻找对应的键值对。

总之,Hashmap通过对key的hashCode值进行取模来实现快速定位元素的位置,从而快速进行添加/删除/查找等操作。当多个元素定位到同一位置时,Hashmap通过链表或红黑树来解决哈希冲突,从而保证查找效率。

关于为什么HashMap不是线程安全的问题,主要是因为在多线程环境下,可能会发生以下两种情况:

  1. 线程竞争:当多个线程同时对HashMap进行修改时,可能会导致数据不一致的情况。例如,如果两个线程同时向同一个桶里添加键值对,其中一个线程可能会覆盖掉另一个线程已经添加的键值对。这可能会导致HashMap中的数据出现异常。
  2. 数据丢失:当一个线程在扩容Hash表的过程中,另一个线程同时向HashMap中添加或删除数据,这就可能会导致一些元素丢失。

因此,在多线程环境下使用HashMap需要注意线程安全问题。可以通过在所有并发访问的代码段加上同步锁(synchronized),或者使用ConcurrentHashMap等线程安全的哈希表来解决这个问题。

put()

HashMap中的put(K key, V value)方法用于向HashMap中添加键值对。其基本原理是,首先通过哈希函数计算出key在数组中对应的索引位置,如果该索引位置没有元素,则直接在该位置插入键值对;如果该索引位置已经有元素,则需要遍历链表或树结构来寻找和key相同的节点,如果找到则更新节点的value值,否则在链表或树结构的末尾添加新节点。

具体的流程如下:

  1. 计算key的哈希值,并计算出桶的索引位置。
  2. 如果该桶还没有任何元素,则直接在该桶的位置插入键值对。
  3. 如果该桶已经存在元素,则遍历该桶中的链表或树结构,查找是否存在相同的key。如果存在,则更新对应节点的value值;如果不存在,则在该链表或树结构的末尾插入新节点。
  4. 如果插入新节点后,该桶中元素的数量超过了阈值(阈值通常为桶的大小*负载因子),则会触发扩容操作。

在HashMap中,如果一个键已经存在,则put方法会覆盖该键的原有值。因此,如果想要检测某个键是否已经存在于HashMap中,可以使用containsKey()方法或者get()方法来实现。

如果只是为了检查某个键是否存在,建议使用containsKey()方法,因为在HashMap中,containsKey()方法比get()方法更高效一些。

环形列表

在多线程环境下,可能会出现HashMap的环形链表问题。当多个线程同时进行put操作时,如果发生了哈希冲突,那么它们可能同时对同一个桶进行操作。由于HashMap中的桶使用链表来存储元素,因此可能会出现多个线程同时将新节点插入到链表的相同位置,从而形成了环形链表。

具体来说,当多个线程同时对同一个桶进行put操作时,如果它们先后进行了resize操作,并且resize之后的新桶中包含了旧桶中的元素,则可能会出现环形链表。这是因为resize操作会将某个桶中的元素移动到新桶中,如果多个线程同时向同一个桶中添加元素,那么可能会出现一些元素已经被移动到了新桶中,但是其他线程并不知道的情况。此时,这些线程可能会在旧桶中的旧位置上形成环形链表,导致程序陷入死循环。

解决这个问题的方法是,在进行put操作时使用锁机制来保证同步,或者使用线程安全的ConcurrentHashMap来避免这个问题。对于需要高并发处理的场景,建议使用ConcurrentHashMap,因为它内部已经实现了分段锁机制,并且相比于HashMap的性能损失也非常小。

多线程resize()

至于多线程同时resize()会怎么样,如果没有加锁处理,可能会产生以下两种情况:

  1. 并发resize()会导致链表成环,从而导致程序进入死循环,无法正常退出。
  2. 如果在resize()期间另一个线程调用了put()方法,由于此时某些桶已经被移动到了新表中,put()方法可能会插入到旧表或新表中的错误位置,最终结果可能是put()操作失败或者数据丢失。

因此,在多线程环境下对HashMap进行resize()操作时,需要采用锁机制来保证线程安全,或者使用线程安全的ConcurrentHashMap。

HashMap扩容

扩容是是新建了一个HashMap的底层数组,而后调用transfer方法,将就HashMap的全部元素添加到新的HashMap中(要重新计算元素在新的数组中的索引位置)。 很明显,扩容是一个相当耗时的操作,因为它需要重新计算这些元素在新的数组中的位置并进行复制处理。因此,我们在用HashMap的时,最好能提前预估下HashMap中元素的个数,这样有助于提高HashMap的性能。

一致性哈希

普通算法:当服务器数量发生改变时,所有缓存在一定时间内是失效的,当应用无法从缓存中获取数据时,则会向后端服务器请求数据;同理,假设突然有一台缓存服务器出现了故障,那么我们则需要将故障机器移除,那么缓存服务器数量从3台变为2台,同样会导致大量缓存在同一时间失效,造成了缓存的雪崩,后端服务器将会承受巨大的压力,整个系统很有可能被压垮。为了解决这种情况,就有了一致性哈希算法。

JDK7 HashMap冲突

哈希表有两种实现方式,一种开放地址方式(Open addressing),另一种是冲突链表方式(Separate chaining with linked lists)。Java7 HashMap采用的是冲突链表方式。

如果选择合适的哈希函数,put()和get()方法可以在常数时间内完成。但在对HashMap进行迭代时,需要遍历整个table以及后面跟的冲突链表。因此对于迭代比较频繁的场景,不宜将HashMap的初始大小设的过大。

有两个参数可以影响HashMap的性能: 初始容量(inital capacity)和负载系数(load factor)。初始容量指定了初始table的大小,负载系数用来指定自动扩容的临界值。当entry的数量超过capacity*load_factor时,容器将自动扩容并重新哈希。对于插入元素较多的场景,将初始容量设大可以减少重新哈希的次数。

而且Java1.7中,hashmap在多线程环境下可能出现死循环:两个线程同时对一个hashmap扩容,在头插法 加 链表 加 多线程并发 加 扩容这几个情形累加到一起就会形成死循环。多线程环境下建议采用ConcurrentHashMap替代。在JDK1.8中,HashMap改成了尾插法,解决了链表死循环的问题。

JDK8 HashMap

根据 Java7 HashMap 的介绍,我们知道,查找的时候,根据 hash 值我们能够快速定位到数组的具体下标,但是之后的话,需要顺着链表一个个比较下去才能找到我们需要的,时间复杂度取决于链表的长度,为 O(n)。

为了降低这部分的开销,在 Java8 中,当链表中的元素达到了 8 个时,会将链表转换为红黑树,在这些位置进行查找的时候可以降低时间复杂度为 O(logN)。

树化意义

  • 红黑树用来避免 DoS 攻击,防止链表超长时性能下降,树化应当是偶然情况,是保底策略
  • hash 表的查找,更新的时间复杂度是 O ( 1 ) O(1) O(1),而红黑树的查找,更新的时间复杂度是 O ( l o g 2 ⁡ n ) O(log_2⁡n ) O(log2n),TreeNode 占用空间也比普通 Node 的大,如非必要,尽量还是使用链表
  • hash 值如果足够随机,则在 hash 表内按泊松分布,在负载因子 0.75 的情况下,长度超过 8 的链表出现概率是 0.00000006,树化阈值选择 8 就是为了让树化几率足够小

树化规则

  • 当链表长度超过树化阈值 8 时,先尝试扩容来减少链表长度,如果数组容量已经 >=64,才会进行树化

退化规则

  • 情况1:在扩容时如果拆分树时,树元素个数 <= 6 则会退化链表
  • 情况2:remove 树节点时,若 root、root.left、root.right、root.left.left 有一个为 null ,也会退化为链表

Java 8中的HashMap使用了数组+链表/红黑树的方式来实现。具体来说,HashMap内部维护了一个Entry数组,每个Entry存储了一个键值对。当多个Entry在哈希冲突时,它们会被添加到同一个链表上。但是,当同一个链表上的节点数超过了某个阈值时,HashMap会将这个链表转化为红黑树,以提高查找效率。

这个阈值由静态变量treeifyThreshold控制,默认值为8。当同一个链表上的节点数大于等于8时,HashMap会判断当前Entry数组大小是否大于等于64,如果是,则进行链表转化为红黑树,否则将数组大小扩大两倍。

阈值是由treeifyThreshold这个静态变量来控制的,可以通过反射的方式修改。不过需要注意,修改阈值可能会影响HashMap的性能和正确性,应该谨慎操作。

ConcurrentHashMap

ConcurrentHashMap 是 Java 并发包中提供的一个线程安全且高效的哈希表实现,其底层基于数组+链表+红黑树的数据结构,并且采用了分段锁机制来实现线程安全和高并发。

具体来说,ConcurrentHashMap 将整个哈希表分成多个称为桶(Segment)的区域,每个桶里面存放着若干个 key-value 对。每个桶都是一个可重入锁(ReentrantLock),不同的桶之间可以并发的执行。

当需要进行 put 操作时,ConcurrentHashMap 会根据 key 的 hash 值找到对应的桶,然后在该桶内部采用 CAS 操作来添加或者更新 key-value 对。而在获取某个 key 的 value 时,ConcurrentHashMap 则会根据 key 的 hash 值找到对应的桶,然后通过读取该桶里面的数据来返回对应的 value,期间可能会进行一些自旋操作以处理并发情况。由于 ConcurrentHashMap 在执行 put、get 等操作时使用了分段锁机制,因此不同的线程可以并发的执行不同的操作,这样就能够提高整个哈希表的并发性和吞吐量。

Segment是ConcurrentHashMap中的一个数组,用于在并发情况下减小锁的粒度,每一个Segment都是一个类似 HashMap 的数据结构,可以独立地进行增删改查操作。每个 Segment 中都包含一个 HashEntry 数组,HashEntry 是 ConcurrentHashMap 内部存储元素的数据结构,由 Key 和 Value 两个部分组成,同时还有一个指针 next 指向下一个 Entry,当产生哈希碰撞的时候,就会将新元素插入到链表末尾,从而形成一条链表。当链表长度超过某个阈值时,这条链表会被转换成红黑树,从而加快查找的速度,同时也减少了哈希碰撞的概率,提高了并发能力。

ConcurrentHashMap扩容

在ConcurrentHashMap的put操作中,首先根据key计算hash值,然后再根据hash值确定存储位置,最后通过锁机制保证线程安全性。如果两个线程同时修改同一个Segment中的不同元素,则它们会获取到不同的锁,互相不会影响,从而保证了并发安全。

由于每个Segment都是独立的,因此在扩容时需要对每个Segment进行扩容操作。在ConcurrentHashMap中,当某个Segment的元素数量达到了阈值时,该Segment会被标记为需要扩容,但是扩容并不会影响其他的Segment。在扩容过程中,会将Segment中的所有元素重新分配到新的Segment中,这个过程可能会比较耗时,在高并发环境下可能会影响性能。

ConcurrentHashMap是Java中线程安全的哈希表实现,它能够支持高并发环境下的快速访问和修改。其底层原理主要包括以下两个方面:

  1. 分段锁: ConcurrentHashMap将整个哈希表分为多个存储桶,每个存储桶都有自己的锁。这种设计可以有效地降低锁的粒度,从而提高并发性能。当需要对某个Key进行操作时,只需要获取对应存储桶的锁,而不会对整个哈希表进行加锁。
  2. CAS算法: ConcurrentHashMap使用了一种基于CAS(Compare And Swap)算法的同步策略,具体来说,它采用了一种乐观锁的方式,利用CAS算法在无锁情况下进行并发更新操作。在更新时,先通过哈希函数计算出该Key所对应的存储桶,然后针对该存储桶中的Entry进行操作,而不是对整个哈希表进行操作。如果要更新的Entry被其他线程修改了,则需要重新计算哈希值并重试。

在一些操作中,例如put()等,需要确保原子性。当CAS失败时,ConcurrentHashMap使用synchronize进行同步,以确保操作的原子性。使用synchronized关键字可以强制所有线程按照同步块中的代码顺序执行,以避免多个线程同时修改一个变量导致的数据竞争问题。

HashMap和Hashtable的区别

  1. **两者最主要的区别在于Hashtable是线程安全,而HashMap则非线程安全。**Hashtable的实现方法里面都添加了synchronized关键字来确保线程同步,因此相对而言HashMap性能会高一些,我们平时使用时若无特殊需求建议使用HashMap,在多线程环境下若使用HashMap需要使用Collections.synchronizedMap()方法来获取一个线程安全的集合(Collections.synchronizedMap()实现原理是Collections定义了一个SynchronizedMap的内部类,这个类实现了Map接口,在调用方法时使用synchronized来保证线程同步,当然了实际上操作的还是我们传入的HashMap实例,简单的说就是Collections.synchronizedMap()方法帮我们在操作HashMap时自动添加了synchronized来实现线程同步,类似的其它Collections.synchronizedXX方法也是类似原理。
  2. **HashMap可以使用null作为key,不过建议还是尽量避免这样使用。**HashMap以null作为key时,总是存储在table数组的第一个节点上。而Hashtable则不允许null作为key
  3. HashMap继承了AbstractMap,HashTable继承Dictionary抽象类,两者均实现Map接口。
  4. HashMap的初始容量为16,Hashtable初始容量为11,两者的填充因子默认都是0.75。
  5. HashMap扩容时是当前容量翻倍即:capacity2,Hashtable扩容时是容量翻倍+1即:capacity2+1。
  6. HashMap数据结构:数组+链表+红黑树(jdk1.8里加入了红黑树的实现,*当链表的长度大于8时,转换为红黑树的结构*,Hashtable数据结构:数组+链表。
  7. HashMap和HashTable都实现了Serializable接口,因此它支持序列化,实现了Cloneable接口,能被克隆。
  8. 两者计算hash的方法不同: Hashtable计算hash是直接使用key的hashcode对table数组的长度直接进行取模:HashMap计算hash对key的hashcode进行了二次hash,以获得更好的散列值,然后对table数组长度取摸:

Hashtable vs ConcurrentHashMap

  • Hashtable 与 ConcurrentHashMap 都是线程安全的 Map 集合
  • Hashtable 并发度低,整个 Hashtable 对应一把锁,同一时刻,只能有一个线程操作它
  • ConcurrentHashMap 并发度高,整个 ConcurrentHashMap 对应多把锁,只要线程访问的是不同锁,那么不会冲突
  1. 线程安全性:ConcurrentHashMap和Hashtable都是线程安全的,但是它们采用不同的实现方式。ConcurrentHashMap使用分段锁机制来提高并发性能,而Hashtable则使用synchronized关键字对整个哈希表进行操作,因此在高并发环境下,ConcurrentHashMap的性能更优。
  2. 效率:由于ConcurrentHashMap可以支持多个线程同时读写不同的桶,因此在高并发环境下,它的性能要比Hashtable好。Hashtable的每个方法都被同步,会导致性能瓶颈。
  3. 初始容量:ConcurrentHashMap可以不指定初始容量,而Hashtable在创建对象时必须指定初始容量。
  4. 键值处理:ConcurrentHashMap对null键值都进行了特殊处理,而Hashtable不允许null键或null值。
  5. 迭代器:ConcurrentHashMap的迭代器弱一致性,而Hashtable则是强一致性,因为在迭代过程中会对整个哈希表加锁,所以Hashtable的迭代器相对较慢。

面试题

装箱拆箱过程

Integer 类型三个变量a,b,c, b = b + c;a = b + c;装箱拆箱过程?是否会创建新的对象?第⼀条语句中两个b是否指向同⼀个对象?

在这个代码中,我们仍然有三个 Integer 类型的变量 a,b 和 c。

  1. 开始时 a、b、c 都为 null。
  2. 在第一行代码执行 b = b + c 时,b 和 c 都被拆箱为基本数据类型的整数,并相加得到一个新的 int 值,将此 int 值自动装箱为一个新的 Integer 对象,并将该对象赋值给 b。因此,这里创建了一个新的 Integer 对象。
  3. 在第二行代码执行 a = b + c 时,b 和 c 又被拆箱为基本数据类型的整数,并相加得到一个新的 int 值,将此 int 值自动装箱为一个新的 Integer 对象,并将该对象赋值给 a。因此,这里再次创建了一个新的 Integer 对象。
  4. 关于第一条语句中的两个 b 变量是否指向同一个对象,这里的 b 虽然在赋值语句中出现了两次,但由于第一次赋值时创建了一个新的 Integer 对象,所以第一条语句中的两个 b 变量并不是指向同一对象。

自定义Srting类

如果要自定义一个String类,我们需要遵循以下步骤:

  1. 定义String类的源代码

我们需要编写一个符合Java语法规范的String类源代码。可以定义在一个名为String.java的文件中,内容如下:

public class String {
    private char[] value;
    
    public String(char[] value) {
        this.value = value;
    }
    
    public char charAt(int index) {
        return value[index];
    }
    
    // 其他方法省略...
}
  1. 使用Java编译器编译源代码

使用Java编译器将String.java文件编译成字节码文件String.class。可以使用命令行工具(如javac)或集成开发环境(如Eclipse、IntelliJ IDEA等)来完成编译过程。

  1. 编写自定义类加载器

我们需要编写一个继承自ClassLoader的自定义类加载器,实现loadClass()方法,以便在加载String类时使用。简单的实现代码如下:

public class MyClassLoader extends ClassLoader {
    public MyClassLoader(ClassLoader parent) {
        super(parent);
    }
    
    @Override
    public Class<?> loadClass(String name) throws ClassNotFoundException {
        if ("java.lang.String".equals(name)) {
            return findClass(name);
        } else {
            return super.loadClass(name);
        }
    }
    
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        byte[] data = getClassData(name);
        
        if (data == null) {
            throw new ClassNotFoundException();
        } else {
            return defineClass(name, data, 0, data.length);
        }
    }
    
    private byte[] getClassData(String name) {
        // 读取String类的字节码文件,返回字节数组
        // 省略实现代码...
    }
}
  1. 测试自定义类加载器

我们可以编写一个测试程序来测试自定义类加载器是否正常工作。代码如下:

public class Test {
    public static void main(String[] args) throws Exception {
        MyClassLoader loader = new MyClassLoader(ClassLoader.getSystemClassLoader());
        Class<?> clazz = loader.loadClass("java.lang.String");
        
        Object str = clazz.getConstructor(char[].class).newInstance(new char[]{'h', 'e', 'l', 'l', 'o'});
        System.out.println(str.getClass().getName()); // 输出 "java.lang.String"
        System.out.println(str.toString()); // 输出 "hello"
        System.out.println(str.charAt(0)); // 输出 "h"
    }
}

在此测试程序中,我们首先创建了一个MyClassLoader实例,并调用它的loadClass()方法来加载String类。然后,我们使用反射API创建了一个String对象,并测试了其toString()和charAt()方法的返回值。

总之,如果要自定义一个String类,我们需要编写源代码、编译成字节码文件,然后使用自定义类加载器加载字节码文件并生成Class对象。在实现过程中,需要注意保持代码的规范性、安全性和可维护性。

new一个虚拟大于物理内存的

如果物理内存只有2GB,而程序运行时尝试使用new来创建一个8GB大小的数组,Java虚拟机将无法为该数组分配足够的连续内存空间,这时会抛出OutOfMemoryError异常,表示内存不足。

具体地说,Java堆空间是所有Java对象的存储区域,如果要为一个对象分配内存,JVM会在堆空间中找到一块足够大的连续区域进行分配。如果当前可用的堆空间不足以容纳一个对象,Java将自动触发一次垃圾回收,尝试释放不再使用的对象占据的空间,使得剩余空间足够分配新的对象。

但是,如果堆空间中可用的空间不足以支持一个连续区域的分配请求,那么Java将无法为对象分配内存,从而抛出了java.lang.OutOfMemoryError异常。

在上述情况下,程序需要优化代码逻辑,避免因为运算溢出或存储大量数据导致内存分配失败的问题。例如,可以采用分页或分段的方式将大数据分割成多个小块进行处理,或者采用更加高效的数据结构和算法来减少内存的消耗,提升程序的性能和稳定性。

总之,Java虚拟机在分配内存时需要考虑物理内存和可用内存的限制,如果无法满足内存分配请求,将会抛出OutOfMemoryError异常,程序需要针对这种情况进行处理和优化。

多线程编程

原则

Java多线程开发需要遵循以下原则:

  1. 避免共享变量的竞争:多个线程访问共享变量时可能会出现竞争,导致数据不一致或产生错误。为了避免这种情况,应该使用同步机制,例如锁或信号量等。
  2. 尽量降低锁的粒度:锁可以保证同一时间只有一个线程访问共享资源,但是过多的锁会导致性能降低。因此,在设计多线程程序时应该尽量将锁的粒度降低到最小。
  3. 保证线程安全:线程安全是指多个线程同时访问一个对象时不会出现问题。在编写多线程程序时,应该考虑如何保证线程安全。
  4. 减少上下文切换的次数:线程切换时需要保存和恢复上下文信息,这个过程会产生额外的开销。因此,应该尽量减少线程切换的次数。
  5. 合理使用线程池:线程池可以提高程序的性能,但是如果线程池中的线程数量过多,反而会影响程序的性能。因此,在使用线程池时应该根据实际需求来设置线程池的大小。
  6. 避免死锁:死锁是指两个或多个线程无限期地等待对方释放资源,导致程序无法继续执行。在编写多线程程序时应该避免出现死锁的情况。
  7. 使用volatile关键字:volatile关键字可以保证多个线程访问变量时的可见性,即一个线程修改了变量的值,其他线程立即看到修改后的值。

线程

线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享进程的方法区资源,但每个线程有自己的程序计数器虚拟机栈本地方法栈,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。

  • 程序(program)为完成特定任务、用某种语言编写的一组指令的集合。即指一段静态的代码,静态对象。
  • 进程(process):程序的一次执行过程,或是正在运行的一个程序。是一个动态的过程:有它自身的产生、存在和消亡的过程。——生命周期。进程作为资源分配的单位,系统在运行时会为每个进程分配不同的内存区域
  • 线程(thread),进程可进一步细化为线程,是一个程序内部的一条执行路径。
    • 若一个进程同一时间并行执行多个线程,就是支持多线程的
    • 线程是调度和执行的单位,每个线程拥有独立的运行栈和程序计数器(pc),线程切换的开销小
    • 一个进程中的多个线程共享相同的内存单元/内存地址空间 一> 它们从同一堆中分配对象,可以访问相同的变量和对象。这就使得线程间通信更简便、高效。但多个线程操作共享的系统资源可能就会带来安全的隐患。
  • 一个Java应用程序java.exe,其实至少有三个线程:main()主线程,gc()垃圾回收线程,异常处理线程。当然如果发生异常,会影响主线程。

优点和用处:

  1. 提高应用程序的响应。对图形化界面更有意义,可增强用户体验。
  2. 提高计算机系统CPU的利用率
  3. 改善程序结构。将既长又复杂的进程分为多个线程,独立运行,利于理解和修改
  • 程序需要同时执行两个或多个任务。程序需要实现一些需要等待的任务时,如用户输入、文件读写操作、网络操作、搜索等。需要一些后台运行的程序时。

程序计数器为什么是私有的?

程序计数器主要有下面两个作用:

  1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

需要注意的是,如果执行的是 native 方法,那么程序计数器记录的是 undefined 地址,只有执行的是 Java 代码时程序计数器记录的才是下一条指令的地址。

所以,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置。

虚拟机栈和本地方法栈为什么是私有的?

  • 虚拟机栈: 每个 Java 方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
  • 本地方法栈: 和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

所以,为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。

线程的创建

  • Java语言的JVM允许程序运行多个线程,它通过java.lang.Thread类来体现。
  • Thread类的特性
    • 每个线程都是通过某个特定Thread对象的run()方法来完成操作的,经常把run()方法的主体称为线程体
    • 通过该Thread对象的start()方法来启动这个线程,而非直接调用run()
  • 优先选择:实现Runnable接口的方式,实现的方式没有类的单继承性的局限性,实现的方式更适合来处理多个线程有共享数据的情况。

在Java中创建线程有以下几种方式:

  1. 继承 Thread 类并重写 run() 方法:此方式需要自定义一个类继承Thread类,并重写其中的run()方法,然后创建该类的实例对象,调用 start() 方法启动线程。
public class MyThread extends Thread {
    @Override
    public void run() {
        // 线程要执行的操作
    }
}

// 创建MyThread的实例对象
MyThread myThread = new MyThread();

// 调用start()方法启动线程
myThread.start();
  1. 实现 Runnable 接口:此方式需要自定义一个类实现Runnable接口,并重写其中的run()方法,然后创建该类的实例对象,并传入Thread类的构造函数中,最后调用 start() 方法启动线程。
public class MyRunnable implements Runnable {
    @Override
    public void run() {
        // 线程要执行的操作
    }
}

// 创建MyRunnable的实例对象
MyRunnable myRunnable = new MyRunnable();

// 通过Thread类的构造函数传入myRunnable对象
Thread thread = new Thread(myRunnable);

// 调用start()方法启动线程
thread.start();
  1. 使用 Callable 和 Future 接口:此方式需要自定义一个类实现Callable接口,并重写其中的call()方法,然后创建该类的实例对象,并将其传递给ExecutorService的submit()方法执行,并返回Future对象,使用该对象的get()方法获取call方法执行结果。
public class MyCallable implements Callable<String> {
    @Override
    public String call() {
        // 线程要执行的操作
        return "Hello World";
    }
}

// 创建MyCallable的实例对象
MyCallable myCallable = new MyCallable();

// 通过ExecutorService的submit()方法执行并返回Future对象
ExecutorService executorService = Executors.newSingleThreadExecutor();
Future<String> future = executorService.submit(myCallable);

// 使用Future对象的get()方法获取call方法的执行结果
String result = future.get();
  1. 使用 Executor 框架中的线程池:此方式需要创建一个线程池,然后将Runnable或Callable实例对象提交给线程池执行。
// 创建一个线程池
ExecutorService executorService = Executors.newFixedThreadPool(10);

// 提交Runnable实例对象给线程池执行
executorService.execute(new Runnable() {
    @Override
    public void run() {
        // 线程要执行的操作
    }
});

// 提交Callable实例对象给线程池执行
Future<String> future = executorService.submit(new Callable<String>() {
    @Override
    public String call() {
        // 线程要执行的操作
        return "Hello World";
    }
});
  1. 使用匿名内部类:使用匿名内部类的方式可以不用自定义类和实例对象,直接创建一个继承Thread类或实现Runnable接口的匿名内部类,并重写其中的run()或call()方法。
// 继承Thread类的匿名内部类
new Thread() {
    @Override
    public void run() {
        // 线程要执行的操作
    }
}.start();

// 实现Runnable接口的匿名内部类
new Runnable() {
    @Override
    public void run() {
        // 线程要执行的操作
    }
};

以上是Java创建线程的五种方式。注意,在启动线程时,应该调用start()方法而不是直接调用run()方法,因为直接调用run()方法只会在当前线程中执行方法调用,而不是创建一个新的线程。

线程状态(生命周期)

  1. NEW(新建):线程创建后处于该状态。此时线程不会执行,还未调用 start() 方法。
  2. RUNNABLE(可运行):线程调用 start() 方法后,线程处于此状态。处于此状态的线程并没有进入运行状态,只是表示它已经具备了运行的条件,等待 CPU 调度执行。
  3. BLOCKED(阻塞):线程处于阻塞状态通常是因为获取某个对象锁失败或者调用了 Thread.sleep() 等方法,或者正在等待 I/O 操作完成。当所等待的操作完成后,线程会转为 RUNNABLE 状态开始等待 CPU 的调度。
  4. WAITING(等待):线程在等待另一个线程执行一个特定的操作时,会进入这个状态。只有当其他线程通知它或者超时时间到达时,它才会被唤醒并转为 RUNNABLE 状态开始执行。
  5. TIMED_WAITING(计时等待):线程在等待一个指定的时间内完成唤醒,或者等待 I/O 操作完成时,会进入此状态。当时间结束或者 I/O 操作完成后,线程会转为 RUNNABLE 状态开始等待 CPU 的调度。
  6. TERMINATED(终止):线程执行完毕或者出现异常而结束时(即执行完 run() 方法),线程处于终止状态。

线程的状态转换如下:

  • 当线程对象调用start()方法启动线程后,线程进入新建状态(New)。
  • 当操作系统选择这个线程开始运行之后,线程进入可运行状态(Runnable)。
  • 线程在运行过程中如果调用了wait()方法,线程进入等待状态(Waiting)。
  • 线程在等待一定时间之后会从等待状态转换为可运行状态(Runnable)。
  • 线程调用sleep()方法,线程进入超时等待状态(TimedWaiting)。
  • 线程在等待指定时间后会自动转换为可运行状态(Runnable)。
  • 线程在获取某个对象的同步锁时如果该锁被其他线程占用,则线程进入阻塞状态(Blocked)。
  • 线程在获取到对象的同步锁后会转换为可运行状态(Runnable)。
  • 线程执行完run()方法后,线程进入终止状态(Terminated)。

WAITING和BLOCKED

WAITING状态是指线程等待某个条件满足,例如调用了wait()方法或join()方法,线程进入WAITING状态,只有在其他线程通知或超时后才能离开WAITING状态。WAITING状态的线程不会占用CPU执行时间片,也就是说不会参与CPU的调度。WAITING状态的线程可以通过notify()/notifyAll()或interrupt()方法唤醒。需要注意的是,在使用wait()方法时,线程会释放掉它所持有的同步锁。

BLOCKED状态是指线程想要进入一个同步块/方法,但该同步块/方法正在被其他线程所持有,线程就会进入BLOCKED状态,直到它获得同步锁才能进入可运行状态。BLOCKED状态的线程不会占用CPU执行时间片,因为它无法继续执行,也就不需要参与CPU的调度。当其他线程释放了同步锁时,处于BLOCKED状态的线程会竞争获得该锁,谁先获得就会进入可运行状态。需要注意的是,在使用synchronized同步机制时,如果不细心设计,容易出现死锁现象,使得多个线程处于BLOCKED状态,程序陷入无限等待的情况。

线程切换

多线程并不一定是要在多核处理器才支持的,就算是单核也是可以支持多线程的。 CPU 通过给每个线程分配一定的时间片,由于时间非常短通常是几十毫秒,所以 CPU 可以不停的切换线程执行任务从而达到了多线程的效果。

但是由于在线程切换的时候需要保存本次执行的信息,在该线程被 CPU 剥夺时间片后又再次运行恢复上次所保存的信息的过程就称为上下文切换。

上下文切换是非常耗效率的。

通常有以下解决方案:

  • 采用无锁编程,比如将数据按照 Hash(id) 进行取模分段,每个线程处理各自分段的数据,从而避免使用锁。
  • 采用 CAS(compare and swap) 算法,如 Atomic 包就是采用 CAS 算法。
  • 合理的创建线程,避免创建了一些线程但其中大部分都是处于 waiting 状态,因为每当从 waiting 状态切换到 running 状态都是一次上下文切换。

面试题

sleep() 方法和 wait() 方法对比

共同点 :两者都可以暂停线程的执行。

  • sleep() 方法没有释放锁,而 wait() 方法释放了锁 。
  • wait() 通常被用于线程间交互/通信,sleep()通常被用于暂停执行。
  • wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify()或者 notifyAll() 方法。sleep()方法执行完成后,线程会自动苏醒,或者也可以使用 wait(long timeout) 超时后线程会自动苏醒。
  • sleep() 是 Thread 类的静态本地方法,wait() 则是 Object 类的本地方法。为什么这样设计呢?
为什么 wait() 方法不定义在 Thread 中?

wait() 是让获得对象锁的线程实现等待,会自动释放当前线程占有的对象锁。每个对象(Object)都拥有对象锁,既然要释放当前线程占有的对象锁并让其进入 WAITING 状态,自然是要操作对应的对象(Object)而非当前的线程(Thread)。

wait 方法必须在同步代码块(synchronized)或者同步方法中执行,这是因为 wait 方法在执行前必须要先获得对象的锁,而 wait 方法会释放锁,并将自己挂起,等待其他线程调用相同对象的 notify 或 notifyAll 方法来唤醒它。如果不在同步块中使用 wait 方法,则会抛出 IllegalMonitorStateException 异常。

另外,wait 方法还需要保证线程安全性,因此使用 wait 方法时必须要在同步代码块中使用,确保多个线程之间能够正确地共享信息、协作完成任务。

类似的问题:为什么 sleep() 方法定义在 Thread 中?

因为 sleep() 是让当前线程暂停执行,不涉及到对象类,也不需要获得对象锁。

可以直接调用 Thread 类的 run 方法吗?

new 一个 Thread,线程进入了新建状态。调用 start()方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。 但是,直接执行 run() 方法,会把 run() 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。

总结: 调用 start() 方法方可启动线程并使线程进入就绪状态,直接执行 run() 方法的话不会以多线程的方式执行。

多生产者多消费者模式
import java.util.LinkedList;
import java.util.Queue;

public class MultiProducerMultiConsumer {

    public static void main(String[] args) {
        Queue<Integer> queue = new LinkedList<>();
        int capacity = 10;

        Thread producer1 = new Thread(new Producer(queue, capacity));
        Thread producer2 = new Thread(new Producer(queue, capacity));
        Thread consumer1 = new Thread(new Consumer(queue));
        Thread consumer2 = new Thread(new Consumer(queue));

        producer1.start();
        producer2.start();
        consumer1.start();
        consumer2.start();
    }

    static class Producer implements Runnable {
        private Queue<Integer> queue;
        private int capacity;

        public Producer(Queue<Integer> queue, int capacity) {
            this.queue = queue;
            this.capacity = capacity;
        }

        public void run() {
            int i = 0;
            while (true) {
                synchronized (queue) {
                    while (queue.size() == capacity) {
                        try {
                            queue.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                    System.out.println("Produced: " + i);
                    queue.add(i++);
                    queue.notifyAll();
                }
            }
        }
    }

    static class Consumer implements Runnable {
        private Queue<Integer> queue;

        public Consumer(Queue<Integer> queue) {
            this.queue = queue;
        }

        public void run() {
            while (true) {
                synchronized (queue) {
                    while (queue.isEmpty()) {
                        try {
                            queue.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                    int val = queue.remove();
                    System.out.println("Consumed: " + val);
                    queue.notifyAll();
                }
            }
        }
    }
}

这个例子中,有两个生产者线程和两个消费者线程。它们共享一个队列,生产者向队列中不断加入数据,而消费者则从队列中取出数据。注意到在生产者和消费者的逻辑中,都要使用synchronized进行同步操作,确保线程安全。

判断死锁

在Java多线程编程中,我们可以使用ThreadMXBean类提供的方法来判断系统是否出现死锁。

具体来说,我们可以通过以下步骤来判断系统是否出现死锁:

  1. 获取ThreadMXBean实例:可以通过ManagementFactory类的getThreadMXBean()方法获取ThreadMXBean实例。
  2. 调用findDeadlockedThreads()方法:该方法返回一个包含所有死锁线程ID的long数组,如果没有死锁,则返回null。
  3. 判断死锁线程:如果findDeadlockedThreads()方法返回非null值,则说明存在死锁线程。可以通过getThreadInfo()方法获取死锁线程的详细信息,如线程ID、线程名称、阻塞对象等。

需要注意的是,ThreadMXBean类在不同的JVM实现中可能有所不同,因此需要根据具体情况选择适合的方法。此外,由于检测死锁需要一定的性能开销,因此应该尽量避免在生产环境中频繁地调用该方法。

线程池

经常创建和销毁、使用量特别大的资源,比如并发情况下的线程,对性能影响很大。提前创建好多个线程,放入线程池中,使用时直接获取,使用完放回池中。可以避免频繁创建销毁、实现重复利用。类似生活中的公共交通工具。

  • 提高响应速度(减少了创建新线程的时间)
  • 降低资源消耗(重复利用线程池中线程,不需要每次都创建)
  • 便于线程管理

创建线程池

方式一:通过ThreadPoolExecutor构造函数来创建(推荐)

ThreadPoolExecutor 3 个最重要的参数:

  • corePoolSize : 任务队列未达到队列容量时,最大可以同时运行的线程数量。
  • maximumPoolSize : 任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。
  • workQueue: 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。

ThreadPoolExecutor其他常见参数 :

  • keepAliveTime:线程池中的线程数量大于 corePoolSize 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime才会被回收销毁;
  • unit : keepAliveTime 参数的时间单位。
  • threadFactory :executor 创建新线程的时候会用到。
  • handler :拒绝策略。关于饱和策略下面单独介绍一下。

方式二:通过 Executor 框架的工具类 Executors 来创建。

Executors:工具类、线程池的工厂类,用于创建并返回不同类型的线程池

  • Executors.newCachedThreadPool():创建一个可根据需要创建新线程的线程池
  • Executors.newFixedThreadPool(n); 创建一个可重用固定线程数的线程池
  • Executors.newSingleThreadExecutor():创建一个只有一个线程的线程池
  • Executors.newScheduledThreadPool(n):创建一个线程池,它可安排在给定延迟后运行命令或者定期地执

线程池当前运行线程数量可以通过 ThreadPoolExecutor 类的 getActiveCount() 方法获取。该方法返回线程池中正在执行任务的线程数量,不包括空闲线程。也可以通过 getPoolSize() 方法获取线程池中的总线程数量,包括正在执行任务和空闲线程。

核心线程数

核心线程数指线程池中保持活动状态的最少线程数,而最大线程数则是线程池中最大允许活动的线程数。如果线程池中的线程数小于核心线程数,那么即使其他线程处于空闲状态,也会创建新的线程,使线程池中的线程数达到核心线程数;如果当前线程池中的线程数已经等于核心线程数,那么就会将任务放置在阻塞队列中。如果阻塞队列已满,那么就会创建新的线程去执行任务,直到线程总数达到最大线程数。如果此时队列仍然满,则新提交的任务会被拒绝或者采用一种特定的拒绝策略来处理。

对于阻塞队列的长度,应该考虑到任务提交速度、处理能力以及系统负载等因素。如果任务提交速度很快,处理能力比较弱,那么阻塞队列的长度可以适当调大,以减少线程创建和销毁的开销。如果系统负载比较高,那么阻塞队列的长度可以适当调小,以避免过多的任务积压而导致系统崩溃。

线程池的keep alive

线程池的keep-alive机制是指当线程池中的线程空闲并且一定时间内没有任务需要处理时,线程池会将这些线程销毁掉,以减少系统资源的占用。而在线程池中,keep-alive机制是通过以下方式实现的:

  1. 线程池中有两个参数来控制线程的keep alive时间,分别是keepAliveTime和TimeUnit。keepAliveTime表示线程空闲的最长时间,即超过该时间没有任务需要处理的线程将被销毁;TimeUnit表示时间单位,可以是秒、毫秒、微秒等。
  2. 当线程池中的线程空闲超过了一定时间(keepAliveTime),线程池会将这些线程销毁掉,以减少系统资源的占用。
  3. Java提供了ScheduledThreadPoolExecutor类来实现线程池的keep-alive机制。这个类可以通过设置参数来控制线程池的keep alive时间,开发者可以通过继承这个类来实现自定义的线程池。具体做法是在继承类的构造方法中调用super(0, TimeUnit.MILLISECONDS),这样就可以禁止超时回收线程,从而实现不限制线程数量的线程池。

总之,线程池的keep-alive机制通过设置空闲时间来控制线程的生命周期,可以避免线程长时间闲置而占用系统资源,提高系统性能。

线程池不够用怎么办

当出现高峰期,线程池中的线程数量不够时,可以采用以下几种方式进行处理:

  1. 扩大线程池:可以通过增加线程池中的最大线程数量来满足高峰期的需求。可以使用线程池的 setMaximumPoolSize() 方法来动态调整线程池中的最大线程数。
  2. 使用线程池的阻塞队列:将线程池中的任务队列替换为更大的阻塞队列,以容纳更多的任务。可以使用线程池的 setQueue() 方法,将任务队列替换为一个可阻塞的队列。
  3. 使用有界队列:可以将任务队列替换为一个有界队列,当任务队列达到最大容量时,新的任务将被拒绝。拒绝策略可以通过设置 RejectedExecutionHandler 参数来进行控制。
  4. 手动创建线程:在线程池任务队列已满且达到最大线程数时,可以使用线程池的拒绝策略自定义执行方式,或手动创建线程来处理任务。

以上方法可以结合使用来满足高峰期的需求,但也需要注意线程数量过多可能会带来额外的开销,而且线程数量过多也会影响程序的执行效率和稳定性。因此,需要根据具体情况选择合适的方案。

线程池的拒绝策略

线程池中的任务缓存队列已满并且线程池中的线程数目达到 maximumPoolSize 时,如果还有任务到来就会采取任务拒绝策略,通常有以下四种策略:

  1. AbortPolicy(默认策略):当线程池的任务缓存队列已满,并且线程池中的线程数目达到 maximumPoolSize 时,则抛出 RejectedExecutionException 异常,拒绝新的任务提交。
  2. DiscardPolicy: 当线程池的任务缓存队列已满,但是线程池中的线程数目未达到 maximumPoolSize 时,则直接丢弃该任务,不抛出任何异常。
  3. DiscardOldestPolicy:丢弃队列最前面的任务,然后重新提交被拒绝的任务。
  4. CallerRunsPolicy:由调用线程(提交任务的线程)处理该任务。也就是说在这个策略下,任务在提交之后,如果线程池没有足够的资源来执行,那么该任务就会由提交该任务的线程来执行。

线程池大小

  • CPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1。比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
  • I/O 密集型任务(2N): 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。

如何判断是 CPU 密集任务还是 IO 密集任务?

CPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。但凡涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。

wait vs sleep

一个共同点,三个不同点

共同点

  • wait() ,wait(long) 和 sleep(long) 的效果都是让当前线程暂时放弃 CPU 的使用权,进入阻塞状态

不同点

  • 方法归属不同

    • sleep(long) 是 Thread 的静态方法
    • 而 wait(),wait(long) 都是 Object 的成员方法,每个对象都有
  • 醒来时机不同

    • 执行 sleep(long) 和 wait(long) 的线程都会在等待相应毫秒后醒来
    • wait(long) 和 wait() 还可以被 notify 唤醒,wait() 如果不唤醒就一直等下去
    • 它们都可以被打断唤醒
  • 锁特性不同(重点)

    • wait 方法的调用必须先获取 wait 对象的锁,而 sleep 则无此限制
    • wait 方法执行后会释放对象锁,允许其它线程获得该对象锁(我放弃 cpu,但你们还可以用)
    • 而 sleep 如果在 synchronized 代码块中执行,并不会释放对象锁(我放弃 cpu,你们也用不了)

ThreadLocal

ThreadLocal 是 Java 中的一个线程级别变量(Thread-Local Variables)的解决方案。它是一个线程本地存储的对象,每个线程都可以根据 ThreadLocal 对象获取独立的副本,而不会互相干扰。ThreadLocal 可以用来解决多线程程序中线程安全的问题,因为它可以将同一个变量在不同线程中的值隔离开来。

ThreadLocal 类提供了 get() 和 set() 方法对其内部的值进行访问和修改。当调用 ThreadLocal 对象的 get() 方法时,它会首先获取当前线程的唯一标识符,用这个标识符作为 key 在 ThreadLocal 内部存储空间中查找对应的变量值,如果变量值存在,则返回该变量值;否则,返回 null。当调用 ThreadLocal 对象的 set() 方法时,它会首先获取当前线程的唯一标识符,用这个标识符作为 key 在 ThreadLocal 内部存储空间中插入或更新对应的变量值。

ThreadLocal 的典型应用场景是在 Web 应用程序中,当用户发起一个请求时,Web 容器会创建一个线程处理这个请求,并将这个请求相关的数据存储到一个 ThreadLocal 中,在整个请求处理过程中,只需要从这个 ThreadLocal 中获取数据即可。因为 ThreadLocal 中的数据对于不同请求的线程是独立的,所以不存在线程安全问题。

作用

  • ThreadLocal 可以实现【资源对象】的线程隔离,让每个线程各用各的【资源对象】,避免争用引发的线程安全问题
  • ThreadLocal 同时实现了线程内的资源共享

原理

每个线程内有一个 ThreadLocalMap 类型的成员变量,用来存储资源对象

  • 调用 set 方法,就是以 ThreadLocal 自己作为 key,资源对象作为 value,放入当前线程的 ThreadLocalMap 集合中
  • 调用 get 方法,就是以 ThreadLocal 自己作为 key,到当前线程中查找关联的资源值
  • 调用 remove 方法,就是以 ThreadLocal 自己作为 key,移除当前线程关联的资源值

ThreadLocalMap 的一些特点

  • key 的 hash 值统一分配
  • 初始容量 16,扩容因子 2/3,扩容容量翻倍
  • key 索引冲突后用开放寻址法解决冲突

弱引用 key

ThreadLocalMap 中的 key 被设计为弱引用,原因如下

  • Thread 可能需要长时间运行(如线程池中的线程),如果 key 不再使用,需要在内存不足(GC)时释放其占用的内存

内存释放时机

  • 被动 GC 释放 key
    • 仅是让 key 的内存释放,关联 value 的内存并不会释放
  • 懒惰被动释放 value
    • get key 时,发现是 null key,则释放其 value 内存
    • set key 时,会使用启发式扫描,清除临近的 null key 的 value 内存,启发次数与元素个数,是否发现 null key 有关
  • 主动 remove 释放 key,value
    • 会同时释放 key,value 的内存,也会清除临近的 null key 的 value 内存
    • 推荐使用它,因为一般使用 ThreadLocal 时都把它作为静态变量(即强引用),因此无法被动依靠 GC 回收

ThreadLocal 优缺点

ThreadLocal 优点:

  1. 线程隔离:使用 ThreadLocal 可以实现同一变量在不同线程中的隔离,每个线程都可以获取到自己独立的变量副本,避免多线程之间相互干扰。
  2. q线程安全:ThreadLocal 可以解决线程安全问题,多个线程可以并发访问一个 ThreadLocal 对象的 get/set 方法,而不会出现竞争和冲突。
  3. 性能高效:ThreadLocal 的性能很高,大部分情况下是 O(1) 的时间复杂度,对系统的性能影响非常小。
  4. 可减少锁的使用:使用 ThreadLocal 可以避免使用锁,从而减少线程间的竞争和阻塞。

ThreadLocal 缺点:

  1. 内存泄漏:如果没有及时清理 ThreadLocal 中的数据,可能会导致内存泄露问题,因为 ThreadLocal 中的数据在当前线程执行完毕后并不会自动释放,需要手动进行清理。
  2. 数据不可共享:由于 ThreadLocal 变量在不同线程中是独立的,所以无法实现线程间数据共享。如果多个线程需要共享一个值,则无法使用 ThreadLocal。
  3. 可能带来上下文切换开销:由于 ThreadLocal 变量需要在不同线程中进行上下文切换,可能会带来一定的性能开销,对于一些高并发场景可能会影响程序的性能。

线程安全

实现线程执行完再执行另一个线程

  1. 使用join()方法: join()方法可以使一个线程等待另一个线程完成后再执行。如果在当前线程中调用join()方法,那么当前线程将会被阻塞,直到被调用的线程(在join()方法中指定的线程)完成运行。
  2. wait()和notify()方法:可以使用wait()方法让一个线程阻塞,直到另一个线程调用notify()方法唤醒它。在实现中,当线程1执行完毕后,可以使用wait()方法阻塞该线程,然后在线程2执行完毕后调用notify()方法唤醒线程1,使其继续执行。
  3. 使用CountDownLatch类:CountDownLatch类可以让一个线程等待一个或多个线程执行完毕后再继续执行。在实现中,可以创建一个CountDownLatch对象,指定其初始计数值为需要等待的线程数量,然后让每个线程执行完毕后调用该对象的countDown()方法,使计数值减1。当计数值减为0时,会自动唤醒等待的线程继续执行。

volite能保证线程安全吗

在Java中,有一个关键字volatile,它的作用是确保多个线程都能够正确地处理被该关键字修饰的变量。具体来说,volatile关键字的含义包括两个方面:

  1. 可见性:当一个变量被volatile修饰时,任何写操作都会立即刷新到主内存中,而任何读操作也都会从主内存中读取。这保证了多个线程对该变量的访问都是基于主内存的,而不是基于线程本地缓存的。
  2. 禁止指令重排序优化:使用volatile关键字修饰的变量,在进行读写操作时会禁止编译器或运行时环境对操作顺序进行优化。

总的来说,使用volatile关键字修饰的变量在多线程环境下可以保证其可见性和一定的有序性,但并不能保证原子性,因此如果需要原子性保证,还需要使用synchronized或Lock等同步机制。

使用volatile关键字修饰的变量可以保证多线程访问该变量时的可见性和一定程度的有序性,但并不能保证线程安全。以下是几个例子来说明:

  1. 写操作与其他操作的组合:如果在写操作和其他操作之间没有同步机制来保证原子性,就可能发生竞态条件(Race Condition)问题,导致线程安全性被破坏。
  2. 复合操作的原子性:对于一些复合操作,如“读取-修改-写入”操作,在使用volatile关键字时仍然需要使用同步机制或者原子操作来保证其原子性。
  3. 没有保证所有操作的原子性:尽管volatile保证了单个volatile变量的读写操作的原子性,但是如果多个volatile变量组合使用时,无法保证这些操作的原子性。

因此,虽然volatile关键字提供了一定程度上的线程安全保障,但它并不能完全保证线程安全。如果需要确保线程安全,不仅需要使用volatile关键字,还需要使用其他同步机制,如synchronized、Lock等。

线程的同步

方式一:同步代码块synchronized(同步监视器)

  • 操作共享数据的代码,即为需要被同步的代码–>不能包含代码多了,也不能包含代码少了。
  • 共享数据:多个线程共同操作的变量。比如:ticket就是共享数据
  • 同步监视器,俗称:锁。任何一个类的对象,都可以来充当锁。要求:多个线程必须要共用同一把锁。

方式二:同步方法,操作共享数据的代码完整的声明在一个方法中

  • 同步的方式,解决了线程的安全问题。但是操作同步代码时,只能有一个线程参与,其他线程等待。相当于是一个单线程的过程,效率低。

方式三:Lock

  • synchronized.机制在执行完相应的同步代码以后,自动的释放同步监视器,Lock需要手动的启动同步(Lock()),同时结束同步也需要手动的实现(unlock())。使用Lock锁,JVM将花费较少的时间来调度线程,性能更好。并且具有更好的扩展性(提供更多的子类)

如何线程安全

Java 中可以采用以下几种方式来保证线程安全:

  1. synchronized关键字:synchronized关键字是Java语言内置的方法级别锁,在保护临界区时非常方便直观。通过使用synchronized,可以确保在同一时间内只有一个线程访问共享资源,从而避免多个线程对共享资源造成的问题。它的主要优点是实现简单、直观易懂,但在高并发场景下缺乏灵活性,可能会导致死锁和性能问题。
  2. Lock接口:Lock接口是Java官方提供的高级锁机制,相较于synchronized关键字,它更具灵活性。Lock可以将锁的申请、获取、释放等行为进行分离,支持手动控制锁状态。常用的实现类有ReentrantLock和ReentrantReadWriteLock,可以满足不同的需求。由于Lock需要手动释放锁,因此需要格外小心避免出现死锁等问题。但是,Lock提供了更多的特性,如可重入锁、公平锁等,功能更加强大。
  3. volatile关键字:volatile关键字用于保证多线程环境下变量的可见性,即一个线程对变量的修改可以立即被其他线程看到。它可以确保每个线程读取到的变量值都是最新的。但是,使用volatile关键字并不能保证原子性,所以它只适用于一些简单的场景,如状态标记变量等,不适用于复杂的计算操作。
  4. 原子类:Java中提供了许多原子类,如AtomicInteger、AtomicLong、AtomicBoolean等,它们可以替代Java中常用的基本数据类型,保证其在多线程环境下的线程安全。因为原子类的方法都是原子型操作,即对多个线程是无锁的,所以可以保证在高并发场景下具有更高的性能。但是,原子类不支持复合操作,若多个操作之间存在依赖关系,就需要使用synchronized关键字或Lock等高级锁机制。
  5. 线程安全的容器类:Java提供了很多线程安全的容器类,如ConcurrentHashMap、CopyOnWriteArrayList、BlockingQueue等,这些容器类都实现了线程安全和高效访问的功能。使用这些容器类可以很方便地实现线程安全,但需要注意其迭代器的使用方式。

以上几种方式各有优劣,开发者需要根据实际情况选择合适的方式。同步锁(synchronized)比较简洁,但在性能上稍逊于Lock接口。Lock接口功能更加丰富,但使用时需要考虑死锁等问题。volatile关键字适用于简单的状态标记变量等场景。原子类只能保证单个操作的原子性。线程安全的容器类使用方便,但需要注意其语义约束。

ThreadLocal

ThreadLocal是Java中的一个线程局部变量,它提供了一种在多线程环境下安全地访问和修改变量的方式。

ThreadLocal的核心原理是每个线程都有自己独立的副本,线程之间互不干扰,这些副本存储在Thread对象的threadLocals属性中,每个ThreadLocal对象都可以存储一个对象副本。每当线程访问或修改变量时,实际上操作的是自己的那个副本。

具体来说,当调用ThreadLocal对象的set方法时,会将当前线程作为key,要设置的对象作为value,存储到Thread对象的threadLocals属性中;当调用get方法时,会根据当前线程获取对应的value。

由于ThreadLocal中存储的对象只对当前线程可见,因此可以在多线程环境下安全地做到数据隔离,不必使用锁进行同步操作,从而提高程序的并发性能和效率。

不过,如果ThreadLocal对象未正确使用,则有可能会导致内存泄漏问题。具体情况包括:

  1. 内存泄漏:当一个ThreadLocal对象被创建后,在程序运行期间一直没有被回收,而在这段时间内,程序又向ThreadLocal中不断放入数据,这些数据就会一直占用内存,无法被GC回收,从而导致内存泄漏问题。可以通过程序线程完成后调用remove方法清除。 ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。所以,如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。
  2. 频繁创建:虽然ThreadLocal的使用不需要加锁,但是频繁创建和回收ThreadLocal对象会带来额外的开销,降低系统性能。可以采用线程池等方式预先创建ThreadLocal对象,避免频繁创建和销毁。
  3. 线程泄漏:如果一个线程持有了ThreadLocal对象,但是这个线程长时间处于WAITING、TIMED_WAITING等状态,一直没有被唤醒,那么这个线程持有的ThreadLocal对象就一直不能被释放,也会导致内存泄漏问题。

lock vs synchronized

不同点

  • 语法层面
    • synchronized 是关键字,源码在 jvm 中,用 c++ 语言实现
    • Lock 是接口,源码由 jdk 提供,用 java 语言实现
    • 使用 synchronized 时,退出同步代码块锁会自动释放,而使用 Lock 时,需要手动调用 unlock 方法释放锁
  • 功能层面
    • 二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能
    • Lock 提供了许多 synchronized 不具备的功能,例如获取等待状态、公平锁、可打断、可超时、多条件变量
    • Lock 有适合不同场景的实现,如 ReentrantLock, ReentrantReadWriteLock
  • 性能层面
    • 在没有竞争时,synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖
    • 在竞争激烈时,Lock 的实现通常会提供更好的性能

公平锁

  • 公平锁的公平体现
    • 已经处在阻塞队列中的线程(不考虑超时)始终都是公平的,先进先出
    • 公平锁是指未处于阻塞队列中的线程来争抢锁,如果队列不为空,则老实到队尾等待
    • 非公平锁是指未处于阻塞队列中的线程来争抢锁,与队列头唤醒的线程去竞争,谁抢到算谁的
  • 公平锁会降低吞吐量,一般不用

条件变量

  • ReentrantLock 中的条件变量功能类似于普通 synchronized 的 wait,notify,用在当线程获得锁后,发现条件不满足时,临时等待的链表结构
  • 与 synchronized 的等待集合不同之处在于,ReentrantLock 中的条件变量可以有多个,可以实现更精细的等待、唤醒控制

在Java中,锁分为两种类型:内置锁(也称为 synchronized锁)和显式锁(也称为API锁)。以下是Java中的锁的几种类型:

  1. 内置锁:也称作 synchronized锁,Java中的一种最基本的锁。它是一种独占锁,同一时刻只能有一个线程获取到锁。当一个线程获取到一个对象的synchronized锁时,其他线程将被阻塞,直到持有锁的线程将锁释放。通过synchronized关键字实现,可以修饰方法和代码块。
  2. ReentrantLock:它是Java提供的一个可重入的独占锁,与synchronized相比,它提供了一些高级特性,例如可中断锁、条件变量、公平锁和非公平锁等,在高并发场景下可以提高性能。需要注意的是,使用完毕后需要手动释放锁。
  3. ReadWriteLock:读写锁是一种特殊的锁,它允许多个线程同时读取共享数据,但在写操作进行时需要互斥。读锁是共享锁,多线程可以同时获取读锁,而写锁是互斥锁,只能有一个线程获取写锁。
  4. StampedLock:它是Java提供的一种乐观锁,与ReadWriteLock相比,在读和写的过程中提供了更高的性能和更细粒度的控制。
  5. CountDownLatch:它是一种同步辅助类,可以在多个线程之间实现协调操作,主要用于等待一个或多个线程完成操作。
  6. Semaphore:也是一种同步辅助类,用于控制同时访问特定资源的线程数量。

可以根据具体的场景和需求选择不同的锁,以确保线程安全和提高性能。

乐观锁和悲观锁

乐观锁是一种乐观思想,假定当前环境是读多写少,遇到并发写的概率比较低,读数据时认为别的线程不会正在进行修改(所以没有上锁)。写数据时,判断当前 与期望值是否相同,如果相同则进行更新(更新期间加锁,保证是原子性的)。

Java中的乐观锁: CAS,比较并替换,比较当前值(主内存中的值),与预期值(当前线程中的值,主内存中值的一份拷贝)是否一样,一样则更新,否则继续进行CAS操作。需要注意的是,CAS操作虽然能够解决数据竞争问题,但并不意味着它能够完全避免死锁或者其它并发异常问题。一个常见的问题是ABA问题,即变量的值在同样被修改并返回到原来的值后可能已经被其它线程进行了修改,而这种修改对于当前线程是不可见的。为解决ABA问题,可以使用带版本号的CAS操作,用版本号来区分相同的值是否被其它线程修改过。

悲观锁是一种悲观思想,即认为写多读少,遇到并发写的可能性高,每次去拿数据的时候都认为其他线程会修改,所以每次读写数据都会认为其他线程会修改,所以每次读写数据时都会上锁。其他线程想要读写这个数据时,会被这个线程block,直到这个线程释放锁然后其他线程获取到锁。悲观锁的实现方式主要有以下几种:

  1. synchronized关键字:在Java中,synchronized关键字可以用来实现悲观锁。当一个线程获取了某个对象的锁之后,其他线程就无法访问该对象的同步代码块或同步方法,只能等待锁被释放。synchronized关键字采用的是互斥锁(mutex),即同一时间只有一个线程能够获得锁,其他线程需要等待。
  2. ReentrantLock类:ReentrantLock是Java中的一个可重入锁,它也能够实现悲观锁。ReentrantLock提供了lock()和unlock()方法,可以手动控制锁的获取和释放。与synchronized关键字不同,ReentrantLock还支持公平锁和非公平锁两种模式,可以更加灵活地控制线程的调度。
  3. synchronized代码块和Lock接口:除了使用synchronized关键字和ReentrantLock类外,我们还可以使用synchronized代码块和Lock接口来实现悲观锁。synchronized代码块的作用范围比synchronized方法更小,可以在粒度更细的情况下进行加锁;而Lock接口提供了更多的锁机制,如读写锁、可重入锁、公平锁等,可以根据实际需求进行选择。

总的来说,悲观锁的实现方式比较简单和直接,但是由于它禁止同时访问共享资源,所以会带来一定的性能损耗和延迟。因此,在实际应用中,我们需要根据具体业务场景和需求,选用合适的锁机制来保证数据的一致性和安全性。

自旋锁

自旋锁是一种技术: 为了让线程等待,我们只须让线程执行一个忙循环(自旋)。

现在绝大多数的个人电脑和服务器都是多路(核)处理器系统,如果物理机器有一个以上的处理器或者处理器核心,能让两个或以上的线程同时并行执行,就可以让后面请求锁的那个线程“稍等一会”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。

自旋锁的优点: 避免了线程切换的开销。挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给Java虚拟机的并发性能带来了很大的压力。

自旋锁的缺点: 占用处理器的时间,如果占用的时间很长,会白白消耗处理器资源,而不会做任何有价值的工作,带来性能的浪费。因此自旋等待的时间必须有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程。

自旋次数默认值:10次,可以使用参数-XX:PreBlockSpin来自行更改。

自适应自旋: 自适应意味着自旋的时间不再是固定的,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。有了自适应自旋,随着程序运行时间的增长及性能监控信息的不断完善,虚拟机对程序锁的状态预测就会越来越精准。

Java中的自旋锁: CAS操作中的比较操作失败后的自旋等待。

可重入锁

可重入锁(Reentrant Lock)和非可重入锁(Non-reentrant Lock)是两种常见的锁类型,它们的主要区别在于当同一个线程反复获取同一把锁时的行为。

可重入锁允许同一个线程反复获取同一把锁而不会发生死锁,因为它可以对每个线程维护一个状态,记录该线程已经获取该锁的次数。在这种情况下,线程只有在调用与释放锁相同数量的次数后,才能完全释放该锁,这被称为锁的重入性。

非可重入锁不允许同一个线程在持有某个锁时再次请求该锁,否则会发生死锁。如果该线程尝试获取该锁,该线程将被阻塞,因为该锁已被该线程持有。

在实际编程中,可重入锁比非可重入锁更为常见。因为它提供了更大的灵活性和更好的代码复用性。例如,如果一个方法A需要获取锁来执行一些任务,同时调用另一个方法B,方法B也需要获取锁,则如果锁是可重入锁,方法B将能够获取锁,否则将导致死锁。

需要注意的是,在使用可重入锁时,要确保在每次获取锁后准确释放锁,否则可能会导致死锁或竞态条件。

可重入锁的作用: 避免死锁。

面试题1: 可重入锁如果加了两把,但是只释放了一把会出现什么问题?

答:程序卡死,线程不能出来,也就是说我们申请了几把锁,就需要释放几把锁。

面试题2: 如果只加了一把锁,释放两次会出现什么问题?

答:会报错,java.lang.IllegalMonitorStateException。

读写锁

读写锁是一种技术: 通过ReentrantReadWriteLock类来实现。为了提高性能, Java 提供了读写锁,在读的地方使用读锁,在写的地方使用写锁,灵活控制,如果没有写锁的情况下,读是无阻塞的,在一定程度上提高了程序的执行效率。读写锁分为读锁和写锁,多个读锁不互斥,读锁与写锁互斥,这是由 jvm 自己控制的。

读锁: 允许多个线程获取读锁,同时访问同一个资源。

写锁: 只允许一个线程获取写锁,不允许同时访问同一个资源。

Java中的读写锁:ReentrantReadWriteLock

公平锁和非公平锁

公平锁是一种思想: 多个线程按照申请锁的顺序来获取锁。在并发环境中,每个线程会先查看此锁维护的等待队列,如果当前等待队列为空,则占有锁,如果等待队列不为空,则加入到等待队列的末尾,按照FIFO的原则从队列中拿到线程,然后占有锁。

非公平锁是一种思想: 线程尝试获取锁,如果获取不到,则再采用公平锁的方式。多个线程获取锁的顺序,不是按照先到先得的顺序,有可能后申请锁的线程比先申请的线程优先获取锁。

优点: 非公平锁的性能高于公平锁。

缺点: 有可能造成线程饥饿(某个线程很长一段时间获取不到锁)

Java中的非公平锁:synchronized是非公平锁,ReentrantLock通过构造函数指定该锁是公平的还是非公平的,默认是非公平的。

偏向锁

在Java中,每次对同步代码块的访问都需要进行加锁操作,如果既有写线程又有读线程,就会涉及到大量的加锁和解锁操作,这样会对程序性能造成一定的影响。而偏向锁是为了解决这个问题而引入的。

偏向锁的基本思想是:当一个线程访问同步代码块时,如果该锁对象没有被其他线程持有,那么当前线程会通过CAS操作把锁对象的Mark Word设置为指向自己的Thread ID,同时将锁对象改为偏向锁状态。以后该线程进入同步块时,无需进行加锁操作,直接进入即可,从而减少了锁的竞争,提高了应用程序的性能。当有其他线程尝试访问同步块时,偏向锁就会失效,变为普通的轻量级锁或者重量级锁,依赖于锁的竞争情况。

偏向锁的使用可以提高单线程访问同步代码块的性能,对多线程的竞争情况影响就比较有限。但如果程序中的锁频繁发生竞争,就会频繁地使偏向锁失效,从而降低程序性能。因此,在使用偏向锁时需要根据实际情况进行权衡和选择。

synchronized

synchronized是Java中的关键字:用来修饰方法、对象实例。属于独占锁、悲观锁、可重入锁、非公平锁。

1.作用于实例方法时,锁住的是对象的实例(this);

2.当作用于静态方法时,锁住的是 Class类,相当于类的一个全局锁, 会锁所有调用该方法的线程;

3.synchronized 作用于一个非 NULL的对象实例时,锁住的是所有以该对象为锁的代码块。它有多个队列,当多个线程一起访问某个对象监视器的时候,对象监视器会将这些线程存储在不同的容器中。

每个对象都有个 monitor 对象, 加锁就是在竞争 monitor 对象,每个对象都有个监视器锁,当Monitor被占用时就会处于锁定状态,线程执行monitorenter时会尝试进入监视器,如果monitor的进入数为0.则该线程进入monitor,且将进入数设置为1,获取有monitor的线程重复进入,则相应加一减一。其他线程尝试进入时,就会阻塞,直到monitor为0,则可获取monitor的进入权。代码块加锁是在代码块前后分别加上 monitorenter 和 monitorexit 指令来实现的,方法加锁是通过一个标记位来判断的。

为了提高 synchronized 的执行效率,JDK 在不同版本中对 synchronized 进行了多次优化。其中主要的优化方案包括:

  1. 锁膨胀(Lock Inflation):在 JDK 1.6 以前,synchronized 是重量级锁,在释放和获取锁时都会从用户态转换成内核态,而转换的效率是比较低的。为了避免频繁进行用户态和内核态之间的切换,JDK 引入了锁膨胀机制,将 synchronized 的状态从无锁、偏向锁、轻量级锁逐步升级到重量级锁,根据不同状态选择适当的锁实现。这样就大幅提升了 synchronized 的性能。
  2. 锁消除(Lock Elimination):JVM 分析代码时,如果发现加在某些对象上的锁实际上不会影响程序的正确性,就会将这些锁直接消除掉,以此来减少锁的竞争和性能损耗。
  3. 锁粗化(Lock Coarsening):将多个连续的加锁、解锁操作合并成一个锁操作,以减少每次加锁、解锁操作所带来的系统开销。
  4. 自适应自旋锁(Adaptive Spinning):在加锁失败时,自旋一段时间再去尝试获取锁,避免线程上下文的频繁切换所带来的性能损失。如果自旋一定时间后还未成功获取锁,则线程放弃自旋,挂起等待唤醒。

Lock和synchronized的区别

Lock: 是Java中的接口,可重入锁、悲观锁、独占锁、互斥锁、同步锁。

1.Lock需要手动获取锁和释放锁。就好比自动挡和手动挡的区别

2.Lock 是一个接口,而 synchronized 是 Java 中的关键字, synchronized 是内置的语言实现。

3.synchronized 在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而 Lock 在发生异常时,如果没有主动通过 unLock()去释放锁,则很可能造成死锁现象,因此使用 Lock 时需要在 finally 块中释放锁。

4.Lock 可以让等待锁的线程响应中断,而 synchronized 却不行,使用 synchronized 时,等待的线程会一直等待下去,不能够响应中断。

5.通过 Lock 可以知道有没有成功获取锁,而 synchronized 却无法办到。

6.Lock 可以通过实现读写锁提高多个线程进行读操作的效率。

synchronized的优势:

足够清晰简单,只需要基础的同步功能时,用synchronized。

Lock应该确保在finally块中释放锁。如果使用synchronized,JVM确保即使出现异常,锁也能被自动释放。

使用Lock时,Java虚拟机很难得知哪些锁对象是由特定线程锁持有的。

volatile关键字

volatile 是 Java 中的一个关键字,用来声明一个变量是“易变的”(即不稳定的或易变化的)。其作用是使该变量在多线程之间可见,即当一个线程修改了该变量的值,其他线程能够立即看到它的最新值。因此,使用 volatile 可以确保线程之间对共享变量的修改能够立即被其他线程看到。

volatile 主要有两个作用:

  1. 确保可见性

可见性是指当一个线程修改了共享变量,其他线程能够立即看到该值的修改。在多线程环境中,线程之间是共享内存的,一个线程对共享变量的修改可能对另一个线程不可见。使用 volatile 关键字可以保证共享变量的可见性,即一个线程修改了该变量的值,其他线程能够立即看到最新值。

  1. 禁止指令重排序

当一个线程执行代码时,JVM 为了提高效率可能会对指令进行重排序,但是这种重排序可能会导致程序的行为出现错误。使用 volatile 关键字可以禁止指令重排序,从而确保程序的执行顺序符合我们设定的顺序。

但是需要注意的是,volatile 并不能保证线程安全。即使变量是 volatile 类型,如果它被多个线程同时读取和写入,还是有可能发生竞态条件问题,需要在代码中使用同步措施(例如 synchronized)来保护共享资源。

综上所述,volatile 关键字主要用于确保共享变量的可见性和禁止指令重排序。但是它并不能保证线程安全,并且使用 volatile 关键字会带来一定的性能损失,因此应该谨慎使用。

原理

在多线程环境中,每个线程都要从主内存中读取共享变量的值到自己的工作内存中进行操作,然后再将结果写回到主内存中。这样就可能导致多个线程同时对同一变量进行操作时出现数据不一致的问题。而使用volatile关键字,可以强制让线程每次都从主内存中读取共享变量的值,从而保证了线程之间对变量的可见性。如果一个线程修改了volatile变量的值,那么其他线程将会立即看到这个变量的最新值,而不是读取到旧值。

在实现时,volatile 的原理是在变量的前一条指令添加一个 Load Barrier 和在变量的后一条指令添加一个 Store Barrier。 Load Barrier 用来防止 Load 和 Load 之间的重排序,Store Barrier 用来防止 Store 和 Store 之间的重排序,同时也保证了 Load 和 Store 之间的有序性。Load Barrier 和 Store Barrier 可以看作是内存屏障,当程序执行到内存屏障时,会停止前后执行指令的流水线,确保执行的顺序符合我们预期的顺序,从而保证了共享变量的可见性和线程安全性。

在Java中,使用Volatile关键字声明变量后,当写操作发生时,JVM会向处理器发送一条lock前缀的指令,这个指令包含一个内存屏障,它告诉处理器不允许该指令前后的指令重排序,而且该指令要立即写回进缓存中。这样,在其他处理器中缓存的该变量的值会失效,其他处理器需要从内存中重新加载该变量的值到它们的缓存中。

当读操作发生时,也会向处理器发送lock前缀的指令,保证读取操作在内存屏障之后,从内存中重新读取新的值。

总的来说,Volatile的原理是通过内存屏障和lock前缀的指令与处理器通信来保证该变量的可见性和禁止指令重排序,在多线程中使用Volatile关键字能够对共享变量进行可靠的读写控制。

ReentrantLock 和synchronized的区别

ReentrantLock是Java中的类 : 继承了Lock类,可重入锁、悲观锁、独占锁、互斥锁、同步锁。

相同点:

1.主要解决共享变量如何安全访问的问题

2.都是可重入锁,也叫做递归锁,同一线程可以多次获得同一个锁,

3.保证了线程安全的两大特性:可见性、原子性。

不同点:

  1. 实现方式不同:synchronized是JVM层面上的锁,而ReentrantLock则是通过JDK实现的API级别的锁。synchronized 是通过 JVM 实现的,当线程试图获取一个被 synchronized 修饰的代码块时,会检查该对象是否被锁定,如果已经被锁定,那么这个线程就会进入阻塞状态。而 ReentrantLock 是通过 Java 代码层面实现的,它的实现方式涉及到锁的升级,具体为无锁、偏向锁、自旋锁、向 OS 申请重量级锁。另外,ReentrantLock 还利用 CAS(CompareAndSwap)自旋机制保证线程操作的原子性和 volatile 保证数据可见性以实现锁的功能。因此,ReentrantLock 的灵活性更高,可控性更好,适用于一些需要更精细控制的场景。
  2. 可重入性:ReentrantLock是一个可重入锁,即在同一线程中可以多次加锁,而synchronized在同一线程中多次加锁会导致死锁。
  3. 锁的获取方式:synchronized是隐式获取锁,即当线程进入同步代码块或方法时自动获取锁;而ReentrantLock则需要显式地获取和释放锁。
  4. 锁的公平性:ReentrantLock可以选择是公平锁还是非公平锁,在构造函数中可以指定;而synchronized只能是非公平锁。
  5. 等待可中断:ReentrantLock支持等待可中断,即能够响应中断而退出等待状态;而synchronized则不支持。
  6. 性能:在资源竞争不是很激烈的情况下,synchronized的性能要优于ReentrantLock;但是在资源竞争很激烈的情况下,synchronized的性能会下降几十倍,而ReentrantLock则不会出现这种情况。

锁的升级(轻量级锁->重量级锁)

Java中的锁升级是指在不同并发访问情况下对锁状态的不同处理。Java中的锁可以分为四种状态:无锁、偏向锁、轻量级锁和重量级锁。

在刚开始时,一个对象处于无锁状态,也就是没有线程持有这个对象的锁。当一个线程获取到对象的锁时,就会使该对象进入偏向锁模式,此时对象头中的标志位信息会被设置为偏向锁,并且记录下持有偏向锁的线程id,以后如果有其他线程来竞争这个锁,就会先判断当前的偏向锁是否被当前线程持有,如果是,则直接执行同步块代码,如果不是,则撤销偏向锁,膨胀成轻量级锁或者重量级锁。在JVM中,锁的升级过程如下:

  1. 偏向锁升级为轻量级锁

当一个线程持有偏向锁的对象并且其他线程也想访问这个对象时,会触发锁的升级。JVM会在对象头上记录原来持有偏向锁的线程id,并将偏向锁的标志位设为0,然后通过CAS操作给对象头换成轻量级锁的状态,此时线程会通过自旋的方式尝试获取轻量级锁。如果自旋成功,则进入临界区执行同步代码块,否则继续升级为重量级锁。

  1. 轻量级锁升级为重量级锁

当一个线程抢占轻量级锁失败时,它就会尝试升级成重量级锁。此时,虚拟机会在当前线程的栈中创建一个Lock Record并尝试使用CAS操作把对象头中指向“轻量级锁”的指针替换成指向“重量级锁”所需的内存空间,如果CAS成功,则代表该对象已经变成重量级锁,此时其他线程就会进入阻塞状态。

轻量级锁缺点

  1. 自旋消耗CPU资源:轻量级锁是通过自旋等待目标线程释放锁来避免线程阻塞的。这种方式虽然避免了线程被阻塞所带来的性能损失,但也会占用CPU资源,可能会导致整个系统的CPU负载升高,影响其他线程的执行效率。
  2. 锁膨胀机制:轻量级锁和重量级锁的切换需要消耗系统资源,当轻量级锁不能成功获取锁时,就需要将它膨胀为重量级锁。如果程序中存在大量的线程竞争情况,就容易引起轻量级锁和重量级锁之间的频繁切换,影响程序的执行效率。
  3. 对象头的额外空间开销:轻量级锁需要在对象头中存储一些额外的信息,如锁标志位、指向线程栈的指针等,这样就会占用一定的内存空间。如果程序中存在大量的对象,就可能会造成额外的内存开销。

Future

Future 类

在 Java 中,Future 类只是一个泛型接口,位于 java.util.concurrent 包下,其中定义了 5 个方法,主要包括下面这 4 个功能:

  • 取消任务;
  • 判断任务是否被取消;
  • 判断任务是否已经执行完成;
  • 获取任务执行结果。

简单理解就是:我有一个任务,提交给了 Future 来处理。任务执行期间我自己可以去做任何想做的事情。并且,在这期间我还可以取消任务以及获取任务的执行状态。一段时间之后,我就可以 Future 那里直接取出任务执行结果。

FutureTask

FutureTask 提供了 Future 接口的基本实现,常用来封装 Callable 和 Runnable,具有取消任务、查看任务是否执行完成以及获取任务执行结果的方法。ExecutorService.submit() 方法返回的其实就是 Future 的实现类 FutureTask 。

FutureTask 不光实现了 Future接口,还实现了Runnable 接口,因此可以作为任务直接被线程执行。

FutureTask 有两个构造函数,可传入 Callable 或者 Runnable 对象。实际上,传入 Runnable 对象也会在方法内部转换为Callable 对象。

CompletableFuture 类

Future 在实际使用过程中存在一些局限性比如不支持异步任务的编排组合、获取计算结果的 get() 方法为阻塞调用。

Java 8 才被引入CompletableFuture 类可以解决Future 的这些缺陷。CompletableFuture 除了提供了更为好用和强大的 Future 特性之外,还提供了函数式编程、异步任务编排组合(可以将多个异步任务串联起来,组成一个完整的链式调用)等能力。

Atomic 原子类

介绍

Atomic 翻译成中文是原子的意思。在化学上,我们知道原子是构成一般物质的最小单位,在化学反应中是不可分割的。在我们这里 Atomic 是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。

所以,所谓原子类说简单点就是具有原子/原子操作特征的类。

并发包 java.util.concurrent 的原子类都存放在java.util.concurrent.atomic下,如下图所示。

类型

基本类型

使用原子的方式更新基本类型

  • AtomicInteger:整型原子类
  • AtomicLong:长整型原子类
  • AtomicBoolean :布尔型原子类

数组类型

使用原子的方式更新数组里的某个元素

  • AtomicIntegerArray:整型数组原子类
  • AtomicLongArray:长整型数组原子类
  • AtomicReferenceArray :引用类型数组原子类

引用类型

  • AtomicReference:引用类型原子类
  • AtomicMarkableReference:原子更新带有标记的引用类型。该类将 boolean 标记与引用关联起来,也可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。
  • AtomicStampedReference :原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。

原理

AtomicInteger 类主要利用 CAS (compare and swap) + volatile 和 native 方法来保证原子操作,从而避免 synchronized 的高开销,执行效率大为提升。

CAS 的原理是拿期望的值和原本的一个值作比较,如果相同则更新成新的值。UnSafe 类的 objectFieldOffset() 方法是一个本地方法,这个方法是用来拿到“原来的值”的内存地址。另外 value 是一个 volatile 变量,在内存中可见,因此 JVM 可以保证任何时刻任何线程总能拿到该变量的最新值。

JUC

JUC(Java Util Concurrent)是Java中提供的一套用于多线程程序的并发工具包,它包含了一些常用的线程安全类和工具类,用于协调线程之间的通信和协作,减少多线程程序中出现的问题。

JUC的主要特点

  1. 线程安全性:JUC提供了一些符合线程安全的类和工具,如ConcurrentHashMap、CopyOnWriteArrayList、AtomicInteger等,能够帮助我们避免多线程访问数据时的竞争和冲突问题。
  2. 高并发性:JUC中的许多类和工具都是为高并发场景设计的,如Semaphore、CountDownLatch、CyclicBarrier等,可以用于线程之间的同步和协调,实现更加高效的并发编程。
  3. 异步编程:JUC支持异步编程的方式,例如Future、CompletionService、Executor等,可以方便地实现多线程任务的异步执行和结果处理。
  4. 扩展性:JUC中的类和接口都是基于接口编程思想设计的,并且提供了一些扩展API和SPI,方便用户自行封装和定制功能,以适应特定的业务需求和设计模式。

组件

JUC提供了一些常用的组件来帮助开发者更轻松地实现多线程编程,这些组件包括:

  1. Lock和ReentrantLock:比synchronized关键字更加灵活和高效的互斥锁机制;
  2. Condition:可以在等待某个条件时挂起线程,并在满足条件时恢复线程执行;
  3. Semaphore:可以控制同时访问某个资源的线程数量;
  4. CountDownLatch:可以让某个线程在其他若干个线程完成后再执行;
  5. CyclicBarrier:让一组线程到达一个屏障(也就是某个共同点)时被阻塞,直到最后一个线程到达屏障时,所有被阻塞的线程才会开始继续执行;
  6. ConcurrentHashMap:线程安全的哈希表,适合高并发读写场景;
  7. BlockingQueue:阻塞队列,可以实现生产者-消费者模式。

AQS

AQS是AbstractQueuedSynchronizer的缩写,是JUC中提供的一个用于实现同步器的框架。AQS本身是一个抽象类,它提供了一些基本的同步操作方法和状态维护机制,同时也预留了一些抽象方法让用户可以通过继承并实现这些方法来定制自己的同步器。

AQS的核心思想是基于FIFO队列和CAS操作实现的,每个AQS实例都维护了一个等待队列(Wait Queue)和一个同步状态(Sync State)。当线程请求获取锁时,如果此时锁已经被占用,则该线程会被加入到等待队列的队尾,阻塞等待前面的线程释放锁。当某个线程释放锁时,它会唤醒等待队列中的第一个线程,使其重新竞争锁。

AQS支持两种类型的同步方式:独占模式和共享模式。其中,独占模式只允许一个线程获取锁,比如ReentrantLock就是一种独占模式的同步器;而共享模式则允许多个线程同时获取锁,比如ReadWriteLock就是一种共享模式的同步器。

AQS的具体实现中,最核心的是3个方法:tryAcquire、tryRelease和tryAcquireShared。其中,tryAcquire和tryRelease分别对应着独占模式下的获取锁和释放锁操作,而tryAcquireShared则对应着共享模式下的获取锁操作。这三个方法实际上就是通过CAS操作来修改同步状态,进而实现对锁的控制。

使用AQS可以相对容易地实现各种类型的同步器,比如锁、信号量等。在使用AQS时,开发者主要需要关注两个方面:一是实现自己的同步器,二是使用已有的同步器。

常见的同步器基于AQS实现,包括:

  1. ReentrantLock:可重入锁,是Java中最常用的锁之一;
  2. ReentrantReadWriteLock:读写锁,适合读多写少的场景;
  3. Semaphore:信号量,可以控制同时访问某个资源的线程数量;
  4. CountDownLatch:倒计时门闩,可以让某个线程在其他若干个线程完成后再执行;
  5. CyclicBarrier:循环栅栏,能够让一组线程到达一个屏障时被阻塞,直到最后一个线程到达屏障时,所有被阻塞的线程才会开始继续执行;
  6. Condition:条件变量,可以在等待某个条件时挂起线程,并在满足条件时恢复线程执行。

基于AQS实现的同步器,在高并发场景下具有较好的性能表现,并且能够提供可靠的同步机制,避免出现死锁、饥饿等问题。

CAS

CAS(Compare And Swap)是一种基于硬件的原子操作,用于实现多线程并发编程中的同步机制。CAS操作具有原子性和非阻塞性,可以在无锁的情况下实现对共享变量的原子性操作。

其基本思想是先比较目标内存地址的值是否与期望值相等,如果相等,则用新值更新目标内存地址的值。整个操作是一个原子性的操作。

在Java中,CAS是由sun.misc.Unsafe类提供的,而JUC中的很多同步器也是基于CAS实现的,比如AtomicInteger、AtomicLong、AtomicReference等。

使用CAS实现原子操作通常包括以下几个步骤:

  1. 首先获取需要进行原子操作的对象的内存地址和当前的值;
  2. 将需要修改的值和当前的值进行比较,如果相等,则进行下一步操作,否则返回失败;
  3. 使用新值替换原来的值,并返回操作结果成功。

在实际应用中,CAS经常与循环结构一起使用,以保证操作的原子性。当CAS操作失败时,就会重新执行整个操作直到成功为止。

面向对象编程

面向对象

Java 是面向对象的编程语言,对象就是面向对象程序设计的核心。所谓对象就是真实世界中的实体,对象与实体是一一对应的,也就是说现实世界中每一个实体都是一个对象,它是一种具体的概念。对象有以下特点:

  • 对象具有属性和行为。
  • 对象具有变化的状态。
  • 对象具有唯一性。
  • 对象都是某个类别的实例。
  • 一切皆为对象,真实世界中的所有事物都可以视为对象。

创建对象的方法

在Java中,创建对象的方法主要有以下几种:

  1. 使用new关键字

使用new关键字可以创建一个新的对象。语法格式如下:

类名 对象名 = new 类名();

例如:

复制代码Person person = new Person();
  1. 使用反射机制

通过反射机制可以在运行时获取类的相关信息,并动态地创建对象。通过Class类的newInstance()方法可以创建一个新的对象。语法格式如下:

Class<?> clazz = Class.forName("类的完整路径");
Object obj = clazz.newInstance();

例如:

Class<?> clazz = Class.forName("com.example.Person");
Person person = (Person)clazz.newInstance();
  1. 使用clone方法

Clone是Object类中的一个方法,它可以复制一个已有的对象,并返回一个新的对象。使用这个方法需要实现Cloneable接口,同时覆盖Object类中的clone()方法。语法格式如下:

class MyObject implements Cloneable {
    public Object clone() throws CloneNotSupportedException {
        return super.clone();
    }
}
MyObject obj = new MyObject();
MyObject newObj = (MyObject)obj.clone();
  1. 使用序列化和反序列化方法

通过序列化和反序列化可以将一个对象从内存中保存到文件或网络中,并在需要的时候重新读取出来。序列化需要实现Serializable接口,反序列化使用ObjectInputStream类。语法格式如下:

// 序列化
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("object.obj"));
oos.writeObject(obj);
oos.close();

// 反序列化
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("object.obj"));
MyObject newObj = (MyObject)ois.readObject();
ois.close();

总之,在Java中创建对象的方法有多种,我们需要根据具体的需求选择合适的方法。

三大核心特性

继承

继承就是子类继承父类的特征和行为,使得子类对象(实例)具有父类的实例域和方法,或子类从父类继承方法,使得子类具有父类相同的行为。

继承应该遵循里氏替换原则,子类对象必须能够替换掉所有父类对象。

封装

利用抽象数据类型将数据和基于数据的操作封装在一起,使其构成一个不可分割的独立实体。数据被保护在抽象数据类型的内部,尽可能地隐藏内部的细节,只保留一些对外接口使之与外部发生联系。用户无需知道对象内部的细节,但可以通过对象对外提供的接口来访问该对象。

  • 减少耦合: 可以独立地开发、测试、优化、使用、理解和修改
  • 减轻维护的负担: 可以更容易被程序员理解,并且在调试的时候可以不影响其他模块
  • 有效地调节性能: 可以通过剖析确定哪些模块影响了系统的性能
  • 提高软件的可重用性
  • 降低了构建大型系统的风险: 即使整个系统不可用,但是这些独立的模块却有可能是可用的

多态

多态性是指允许不同子类型的对象对同一消息作出不同的响应。

多态分为编译时多态和运行时多态:

  • 编译时多态主要指方法的重载
  • 运行时多态指程序中定义的对象引用所指向的具体类型在运行期间才确定

运行时多态有三个条件:

  • 继承
  • 覆盖(重写)
  • 向上转型

面向过程的区别

(1)编程思路不同:面向过程以实现功能的函数开发为主,而面向对象要首先抽象出类、属性及其方法,然后通过实例化类、执行方法来完成功能。

(2)封装性:都具有封装性,但是面向过程是封装的是功能,而面向对象封装的是数据和功能。

(3)面向对象具有继承性和多态性,而面向过程没有继承性和多态性,所以面向对象优势很明显

类和对象

面向对象的两个要素

  • 类:对一类事物的描述,是抽象的、概念上的定义
  • 对象:是实际存在的该类事物的每个个体,因而也称为实例(instance)。

面向对象程序设计的重点是类的设计;设计类,其实就是设计类的成员。

类(对象)之间的关系

  • 泛化关系(Generalization):泛化关系其实就是继承关系,在 Java 中使用 extends 关键字。

  • 实现关系 (Realization):用来实现一个接口,在 Java 中使用 implement 关键字。

  • 聚合关系 (Aggregation):表示整体由部分组成,但是整体和部分不是强依赖的,整体不存在了部分还是会存在。聚合表示一种弱的*拥有*关系,体现的A(雁群)对象可以包含B(大雁)对象,但B对象不是A的对象的有一部分

  • 组合关系 (Composition):和聚合不同,组合中整体和部分是强依赖的,整体不存在了部分也不存在了,组合是一种强的拥有关系,体现了严格的部分和整体的关系,部分和整体的生命周期一样。例如鸟和翅膀的关系。

  • 关联关系 (Association):表示不同类对象之间有关联,这是一种静态关系,与运行过程的状态无关,在最开始就可以确定。因此也可以用 1 对 1、多对 1、多对多这种关联关系来表示。

  • 依赖关系 (Dependency):和关联关系不同的是,依赖关系是在运行过程中起作用,动物生存需要在依赖于氧气和水,这个是需要在某一个场景下才能构成依赖。

类(Class)

类的构成

在Java中,类是一种具有相似属性和行为的对象的模板。类定义了一个对象应该具有的属性(即变量)和方法。对象是类的实例,它们共享类提供的变量和方法集合。以下是Java中类的构成部分:

  1. 成员变量(Field):类的成员变量是对象的属性,也称为字段或属性。 这些变量定义了对象将包含哪些数据。属性可以有不同的访问修饰符(public, private, protected, default)来控制其可见性和访问权限。属性可以在类中直接赋值,也可以通过构造方法或普通方法来赋值或修改。
  2. 方法(Method):类的方法是实现操作的代码段,它们定义了对象的行为。方法也可以有不同的访问修饰符(public, private, protected, default)来控制其可见性和访问权限。方法的定义包括以下部分:修饰符、返回值类型、方法名、参数列表、异常声明和方法体。方法可以在类中被调用,也可以被其他类或对象调用。
  3. 构造方法(Constructors):用于创建实例对象的方法,在类实例化(即创建对象)时自动调用构造函数。一个类可以有多个构造方法。
  4. 内部类(Inner Class):定义在另一个类中的类,可以访问外部类的私有成员。
  5. 代码块(Code Block):一段被大括号包围的代码,可以执行一些初始化操作。

类的分类

  1. 普通类:普通类是最基本的类,通常用于表示具体的事物或实现某种功能。这种类可以包含属性、方法,也可以继承其他类或实现接口。
  2. 抽象类:抽象类是一种特殊的类,它不能被实例化,只能被用作其他类的父类。抽象类包含抽象方法,这些方法没有实现,只是定义了方法的签名。
  3. 接口:接口是一组抽象方法的集合,它定义了某个类或类群应该遵循的行为规范。一个类可以实现多个接口,但不能继承多个类。
  4. 内部类:内部类是定义在另一个类内部的类,它可以访问其外部类的私有成员并且可以实现更加复杂的类之间的关系和交互。
  5. 匿名类:匿名类是一种没有名字的类,通常作为方法参数或作为一个新建的对象来使用。这种类通常是临时创建的,用于实现某个特定的接口或抽象类。

除此之外,还有很多特殊的类,如枚举类、注解类等。每种类型的类都有自己的特点和用途,可以根据具体需求选择使用。

内部类

在Java中,内部类(Inner class)是指一个定义在另一个类中的类。内部类可以访问外部类的成员变量和方法,并且可以使代码更加模块化和封装,提高了代码的可读性和可维护性。

Java中有四种类型的内部类:

  1. 成员内部类(Member Inner Class):定义在外部类的实例域中,与外部类的实例相关联。成员内部类可以访问外部类的所有成员变量和方法,包括private成员。成员内部类常用于一些需要访问外部类数据的场景,比如说迭代器等。
  2. 静态内部类(Static Inner Class):定义在外部类中,但是必须使用static关键字进行修饰。静态内部类不能访问外部类的非static成员,但是它可以拥有自己的成员变量和方法。静态内部类常用于一些只需要使用外部类的静态数据的场景。
  3. 局部内部类(Local Inner Class):定义在方法内部,只能在该方法内部被访问。局部内部类可以访问外部类的所有成员变量和方法,但是外部类不能直接访问局部内部类的成员变量和方法。局部内部类常用于一些需要很短寿命对象的场景,比如说线程池等。
  4. 匿名内部类(Anonymous Inner Class):没有类名的内部类,只能通过实例化的方式来使用。匿名内部类可以继承已有的类或者实现已有的接口,它没有构造方法,但是可以有初始化块。匿名内部类常用于一些只需要使用一次的场景,比如说事件处理器等。

类的生命周期

其中类加载的过程包括了加载、验证、准备、解析、初始化五个阶段。

在这五个阶段中,加载、验证、准备和初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持Java语言的运行时绑定(也成为动态绑定或晚期绑定)。另外注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。

类的加载

加载时类加载过程的第一个阶段,在加载阶段,虚拟机需要完成以下三件事情:

  • 通过一个类的全限定名来获取其定义的二进制字节流。
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  • 在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口。

相对于类加载的其他阶段而言,加载阶段(准确地说,是加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,因为开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。

加载阶段完成后,虚拟机外部的 二进制字节流就按照虚拟机所需的格式存储在方法区之中,而且在Java堆中也创建一个java.lang.Class类的对象,这样便可以通过该对象访问方法区中的这些数据。

类加载器并不需要等到某个类被“首次主动使用”时再加载它,JVM规范允许类加载器在预料某个类将要被使用时就预先加载它,如果在预先加载的过程中遇到了.class文件缺失或存在错误,类加载器必须在程序首次主动使用该类时才报告错误(LinkageError错误)如果这个类一直没有被程序主动使用,那么类加载器就不会报告错误。

加载.class文件的方式

  • 从本地系统中直接加载
  • 通过网络下载.class文件
  • 从zip,jar等归档文件中加载.class文件
  • 从专有数据库中提取.class文件
  • 将Java源文件动态编译为.class文件

连接

验证: 确保被加载的类的正确性

验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段大致会完成4个阶段的检验动作:

  • 文件格式验证: 验证字节流是否符合Class文件格式的规范;例如: 是否以0xCAFEBABE开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型。
  • 元数据验证: 对字节码描述的信息进行语义分析(注意: 对比javac编译阶段的语义分析),以保证其描述的信息符合Java语言规范的要求;例如: 这个类是否有父类,除了java.lang.Object之外。
  • 字节码验证: 通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
  • 符号引用验证: 确保解析动作能正确执行。

验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响,如果所引用的类经过反复验证,那么可以考虑采用*-Xverifynone**参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。*

准备: 为类的静态变量分配内存,并将其初始化为默认值

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:

  • 这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中。

  • 这里所设置的初始值通常情况下是数据类型默认的零值(如0、0L、null、false等),而不是被在Java代码中被显式地赋予的值。

    假设一个类变量的定义为: public static int value = 3;那么变量value在准备阶段过后的初始值为0,而不是3,因为这时候尚未开始执行任何Java方法,而把value赋值为3的put static指令是在程序编译后,存放于类构造器()方法之中的,所以把value赋值为3的动作将在初始化阶段才会执行。

这里还需要注意如下几点

  • 对基本数据类型来说,对于类变量(static)和全局变量,如果不显式地对其赋值而直接使用,则系统会为其赋予默认的零值,而对于局部变量来说,在使用前必须显式地为其赋值,否则编译时不通过。
  • 对于同时被static和final修饰的常量,必须在声明的时候就为其显式地赋值,否则编译时不通过;而只被final修饰的常量则既可以在声明时显式地为其赋值,也可以在类初始化时显式地为其赋值,总之,在使用前必须为其显式地赋值,系统不会为其赋予默认零值。
  • 对于引用数据类型reference来说,如数组引用、对象引用等,如果没有对其进行显式地赋值而直接使用,系统都会为其赋予默认的零值,即null。
  • 如果在数组初始化时没有对数组中的各元素赋值,那么其中的元素将根据对应的数据类型而被赋予默认的零值。
  • 如果类字段的字段属性表中存在ConstantValue属性,即同时被final和static修饰,那么在准备阶段变量value就会被初始化为ConstValue属性所指定的值。假设上面的类变量value被定义为: public static final int value = 3;编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为3。我们可以理解为static final常量在编译期就将其结果放入了调用它的类的常量池中

解析: 把类中的符号引用转换为直接引用

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。符号引用就是一组符号来描述目标,可以是任何字面量。

直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。

初始化

初始化,为类的静态变量赋予正确的初始值,JVM负责对类进行初始化,主要对类变量进行初始化。在Java中对类变量进行初始值设定有两种方式:

  • 声明类变量是指定初始值
  • 使用静态代码块为类变量指定初始值

JVM初始化步骤

  • 假如这个类还没有被加载和连接,则程序先加载并连接该类
  • 假如该类的直接父类还没有被初始化,则先初始化其直接父类
  • 假如类中有初始化语句,则系统依次执行这些初始化语句

类初始化时机: 只有当对类的主动使用的时候才会导致类的初始化,类的主动使用包括以下六种:

  • 创建类的实例,也就是new的方式
  • 访问某个类或接口的静态变量,或者对该静态变量赋值
  • 调用类的静态方法
  • 反射(如Class.forName(“com.pdai.jvm.Test”))
  • 初始化某个类的子类,则其父类也会被初始化
  • Java虚拟机启动时被标明为启动类的类(Java Test),直接使用java.exe命令来运行某个主类

使用

类访问方法区内的数据结构的接口, 对象是Heap区的数据。

卸载

Java虚拟机将结束生命周期的几种情况

  • 执行了System.exit()方法
  • 程序正常执行结束
  • 程序在执行过程中遇到了异常或错误而异常终止
  • 由于操作系统出现错误而导致Java虚拟机进程终止

Java 类加载机制

三级委托模型机制

Java的类加载机制(Class Loading Mechanism)是指将类的字节码文件(*.class)加载到JVM中并生成对应的Class对象的过程。在Java中,类加载机制在程序运行时动态地进行,需要根据类的引用情况动态加载和卸载类。

Java的类加载机制采用了“三级委托模型”(Three-Level Delegation Model),即从下到上分别是:Bootstrap Class Loader、Extension Class Loader和System Class Loader。每个类加载器都有其对应的加载路径和搜索范围。

  • Bootstrap Class Loader

Bootstrap Class Loader是Java类加载机制中的“根加载器”,也称为引导类加载器。它负责加载Java运行时环境核心库(如rt.jar)中的类,并不直接继承自java.lang.ClassLoader,而是由JVM本身实现。因此,无法通过Java代码来获取引导类加载器对象。

  • Extension Class Loader

Extension Class Loader是Java类加载机制中的扩展类加载器,负责加载Java运行时环境扩展库(如jce.jar、dnsns.jar等)中的类。它是由sun.misc.Launcher$ExtClassLoader类来实现的,其父类加载器为引导类加载器。

  • System Class Loader

System Class Loader是Java类加载机制中的系统类加载器,负责加载应用程序classpath环境变量(包括用户自定义的类)中的类。它是由sun.misc.Launcher$AppClassLoader类来实现的,其父类加载器为扩展类加载器。

除了内置的类加载器之外,Java还提供了自定义类加载器,用于动态加载特定路径或者其他来源(如网络、数据库等)中的类。自定义类加载器需要继承自java.lang.ClassLoader,并覆盖loadClass()方法或findClass()方法,以实现对类的加载和解析。

类加载机制的优点:

  1. 动态性:Java的类加载机制允许在程序运行时动态地加载和卸载类,避免了静态编译时确定类依赖关系的局限性。
  2. 安全性:Java的类加载机制可以防止恶意代码的运行和篡改,保证Java平台的安全性。
  3. 隔离性:Java的类加载机制可以隔离不同的类,避免类之间的干扰和冲突,保证类的独立性。

类加载器

启动类加载器: Bootstrap ClassLoader,负责加载存放在JDK\jre\lib(JDK代表JDK的安装目录,下同)下,或被-Xbootclasspath参数指定的路径中的,并且能被虚拟机识别的类库(如rt.jar,所有的java.*开头的类均被Bootstrap ClassLoader加载)。启动类加载器是无法被Java程序直接引用的。

扩展类加载器: Extension ClassLoader,该加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载JDK\jre\lib\ext目录中,或者由java.ext.dirs系统变量指定的路径中的所有类库(如javax.*开头的类),开发者可以直接使用扩展类加载器。

应用程序类加载器: Application ClassLoader,该类加载器由sun.misc.Launcher$AppClassLoader来实现,它负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

应用程序都是由这三种类加载器互相配合进行加载的,如果有必要,我们还可以加入自定义的类加载器。因为JVM自带的ClassLoader只是懂得从本地文件系统加载标准的java class文件,因此如果编写了自己的ClassLoader,便可以做到如下几点:

  • 在执行非置信代码之前,自动验证数字签名。
  • 动态地创建符合用户特定需要的定制化构建类。
  • 从特定的场所取得java class,例如数据库中和网络中。

类的加载

类加载有三种方式:

  1. 命令行启动应用时候由JVM初始化加载
  2. 通过Class.forName()方法动态加载
  3. 通过ClassLoader.loadClass()方法动态加载

Class.forName()和ClassLoader.loadClass()区别?

  • Class.forName(): 将类的.class文件加载到jvm中之外,还会对类进行解释,执行类中的static块;
  • ClassLoader.loadClass(): 只干一件事情,就是将.class文件加载到jvm中,不会执行static中的内容,只有在newInstance才会去执行static块。
  • Class.forName(name, initialize, loader)带参函数也可控制是否加载static块。并且只有调用了newInstance()方法采用调用构造函数,创建类的对象 。
  • Class.forName()是一个静态方法,而ClassLoader.loadClass()则是一个实例方法。Class.forName()方法会根据指定的名称(包括该类的全限定名)来加载对应的类,如果类不存在或无法被加载,会抛出ClassNotFoundException异常;而ClassLoader.loadClass()方法则需要通过ClassLoader实例来进行调用,该方法不会初始化类,只是将对应的.class文件加载到内存中。
  • Class.forName()方法在加载类时会执行类的静态代码块,并且初始化该类,而ClassLoader.loadClass()方法只能完成类的加载,而不会执行其静态代码块或初始化该类。当然,使用ClassLoader.loadClass()方法后,可以通过调用Class.forName()方法来强制加载该类并执行其静态代码块。

JVM类加载机制

  • 全盘负责,当一个类加载器负责加载某个Class时,该Class所依赖的和引用的其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入
  • 父类委托,先让父类加载器试图加载该类,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类
  • 缓存机制,缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区寻找该Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓存区。这就是为什么修改了Class后,必须重启JVM,程序的修改才会生效
  • 双亲委派机制, 如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。

双亲委派机制过程

  1. 当AppClassLoader加载一个class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtClassLoader去完成。
  2. 当ExtClassLoader加载一个class时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootStrapClassLoader去完成。
  3. 如果BootStrapClassLoader加载失败(例如在$JAVA_HOME/jre/lib里未查找到该class),会使用ExtClassLoader来尝试加载;
  4. 若ExtClassLoader也加载失败,则会使用AppClassLoader来加载,如果AppClassLoader也加载失败,则会报出异常ClassNotFoundException。

双亲委派优势

双亲委派机制是一种Java类加载器的工作机制,即在当前类加载器无法完成类加载任务时,先委托父类加载器去加载该类,一直逐级向上委托,直到顶层的Bootstrap ClassLoader。如果Bootstrap ClassLoader也无法加载该类,则回到Application ClassLoader,由它自定义类加载方式进行加载。

这种机制可以保证Java程序的稳定性和安全性。因为如果类加载器请求加载某个类时,首先判断是否已经被加载过了,如果已经被加载,则直接返回对应的Class对象;如果没有被加载,那么就按照双亲委派机制去委派父类加载器加载,避免同名类的冲突和重复加载。

在双亲委派机制下,类一旦被某个ClassLoader加载了,它就会一直存在于这个ClassLoader的命名空间中,直到JVM退出或者ClassLoader被GC回收。这种机制在Java中起到了至关重要的作用,确保了Java程序的运行安全性和稳定性。

继承

规定

  1. 一个类可以多个类继承 ,而一个类只能有一个父类。子父类是相对的概念。
  2. 子类直接继承的父类,称为:直接父类。间接继承的父类,称为,间接父类。
  3. 子类继承父类后,就获取了直接父类以及所有间接父类中声明的属性和方法。
  4. 如果我们没有显式的声明一个类的父类的话,则此类继承于 java.lang.Object

访问权限

Java 中有三个访问权限修饰符: private、protected 以及 public,如果不加访问修饰符,表示包级可见。

可以对类或类中的成员(字段以及方法)加上访问修饰符。

  • 类可见表示其它类可以用这个类创建实例对象。
  • 成员可见表示其它类可以用这个类的实例对象访问到该成员;

设计良好的模块会隐藏所有的实现细节,把它的 API 与它的实现清晰地隔离开来。模块之间只通过它们的 API 进行通信,一个模块不需要知道其他模块的内部工作情况,这个概念被称为信息隐藏或封装。因此访问权限应当尽可能地使每个类或者成员不被外界访问。

如果子类的方法重写了父类的方法,那么子类中该方法的访问级别不允许低于父类的访问级别。这是为了确保可以使用父类实例的地方都可以使用子类实例,也就是确保满足里氏替换原则

抽象类与接口

  1. 抽象类

父类中的方法,被他的子类们重写,子类各自的实现都不尽相同。那么父类的方法声明和方法主体,只有声明还有 意义,而方法主体则没有存在的意义了。我们把没有方法主体的方法称为抽象方法。Java语法规定,包含抽象方法 的类就是抽象类

总的来说就是:

  • 抽象方法:没有方法体的方法
  • 抽象类:包含抽象方法的类

抽象类和普通类最大的区别是,抽象类不能被实例化,需要继承抽象类才能实例化其子类。抽象类的子类,必须重写抽象父类中所有的抽象方法,否则,编译无法通过而报错。除非该子类也是抽象类。

  1. 接口

接口是抽象类的延伸,在 Java 8 之前,它可以看成是一个完全抽象的类,也就是说它不能有任何的方法实现。

从 Java 8 开始,接口也可以拥有默认的方法实现,这是因为不支持默认方法的接口的维护成本太高了。在 Java 8 之前,如果一个接口想要添加新的方法,那么要修改所有实现了该接口的类。

接口的成员(字段 + 方法)默认都是 public 的,并且不允许定义为 private 或者 protected。

接口的字段默认都是 static 和 final 的。

  1. 比较
  • 从设计层面上看,抽象类提供了一种 IS-A 关系,那么就必须满足里式替换原则,即子类对象必须能够替换掉所有父类对象。而接口更像是一种 LIKE-A 关系,它只是提供一种方法实现契约,并不要求接口和实现接口的类具有 IS-A 关系。

  • 一个子类只能继承一个抽象类, 但能实现多个接口

  • 抽象类可以有普通成员变量, 接口没有普通成员变量,接口的成员只能是 public 的,而抽象类的成员可以有多种访问权限。

  • 抽象类和接口都可有静态成员变量, 抽象类中静态成员变量访问类型任意,接口只能public static final(默认)

  • 抽象类可以没有抽象方法, 抽象类可以有普通方法;接口在JDK8之前都是抽象方法,在JDK8可以有default方法,在JDK9中允许有私有普通方法

  • 抽象类可以有静态方法;接口在JDK8之前不能有静态方法,在JDK8中可以有静态方法,且只能被接口类直接调用(不能被实现类的对象调用)

  • 抽象类中的方法可以是public、protected; 接口方法在JDK8之前只有public abstract,在JDK8可以有default方法,在JDK9中允许有private方法

    区别点抽象类接口
    定义包含抽象方法的类主要是抽象方法和全局常量的集合
    组成构造方法、抽象方法、普通方法、常量、变量常量、抽象方法、(jdk8.0:默认方法、静态方法)接口没有构造方法
    使用子类继承抽象类(extends)子类实现接口(implements)
    关系抽象类可以实现多个接口接口不能继承抽象类,但允许继承多个接口
    常见设计模式模板方法简单工厂、工厂方法、代理模式
    对象都通过对象的多态性产生实例化对象
    局限抽象类有单继承的局限接口没有此局限
    实际作为一个模板是作为一个标准或是表示一种能力
    选择如果抽象类和接口都可以使用的话,优先使用接口,因为避免单继承的局限
  1. 使用选择

使用接口:

  • 需要让不相关的类都实现一个方法,例如不相关的类都可以实现 Compareable 接口中的 compareTo() 方法;
  • 需要使用多重继承。

使用抽象类:

  • 需要在几个相关的类中共享代码。
  • 需要能控制继承来的成员的访问权限,而不是都为 public。
  • 需要继承非静态和非常量字段。

在很多情况下,接口优先于抽象类,因为接口没有抽象类严格的类层次结构要求,可以灵活地为一个类添加行为。并且从 Java 8 开始,接口也可以有默认的方法实现,使得修改接口的成本也变的很低。

super

我们可以在子类的方法或构造器中,通过"super"属性"或"super.方法"的方式,显式的调用父类中声明的属性或方法。但是,通常情况下,我们习惯去省略这个"super."

当子类和父类中定义了同名的属性时,或者重写了父类中的方法后,我们要想在子类中调用父类中声明的属性或者方法,则必须显式的使用"super.属性"的方式,表明调用的是父类中声明的属性或者方法。

  • 我们可以在子类的构造器中显式的使用"super(形参列表)"的方式,调用父类中声明的指定的构造器,调用super()必须写在子类构造方法的第一行, 否则编译不通过。
  • 尽管可以用this调用一个构造器, 却不能调用2个,我们在类的构造器中,针对于"this(形参列表)"或"super(形参列表)"只能二选一,不能同时出现。
  • 在构造器的首行,既没有显式的声明"this(形参列表)“或"super(形参列表)”,则默认的调用的是父类中的空参构造器。
  • this()、super()都指的对象,不可以在static环境中使用
  • 本质this指向本对象的指针。super是一个关键字

当我们通过子类的构造器创建子类对象时,我们一定会直接或间接的调用其父类构造器,直到调用了java.lang.Object类中空参的构造器为止。正因为加载过所有的父类结构,所以才可以看到内存中有父类中的结构,子类对象可以考虑进行调用。

虽然创建子类对象时,调用了父类的构造器,但自始至终就创建过一个对象,即为new的子类对象。

重写与重载

  1. 重写(Override)

存在于继承体系中,指子类实现了一个与父类在方法声明上完全相同的一个方法。

为了满足里式替换原则,重写有以下两个限制:

  • 子类重写的方法的方法名和形参列表必须和父类被重写的方法的方法名、形参列表相同
  • 子类重写的方法使用的访问权限不能小于父类被重写的方法的访问权限,子类不能重写父类中声明为private权限的方法
  • 子类方法的访问权限必须大于等于父类方法;
  • 子类方法的返回类型必须是父类方法返回类型或为其子类型。
  • 子类重写的方法的返回值类型必须是与父类相同的基本数据类型。
  • 类方法抛出的异常不能大于父类被重写的方法抛出的异常

使用 @Override 注解,可以让编译器帮忙检查是否满足上面的两个限制条件。

  1. 重载(Overload)

存在于同一个类中,指一个方法与已经存在的方法名称上相同,但是参数类型、个数、顺序至少有一个不同。

应该注意的是,返回值不同,其它都相同不算是重载。

  1. Java 中是否可以重写一个 private 或者 static 方法

Java 中 static 方法不能被覆盖,因为方法覆盖是基于运行时动态绑定的,而 static 方法是编译时静态绑定的。static 方法跟类的任何实例都不相关,所以概念上不适用。

Java 中也不可以覆盖 private 的方法,因为 private 修饰的变量和方法只能在当前类中使用, 如果是其他的类继承当前类是不能访问到 private 变量或方法的,当然也不能覆盖。

静态方法补充:静态的方法可以被继承,但是不能重写。如果父类和子类中存在同样名称和参数的静态方法,那么该子类的方法会把原来继承过来的父类的方法隐藏,而不是重写。通俗的讲就是父类的方法和子类的方法是两个没有关系的方法,具体调用哪一个方法是看是哪个对象的引用;这种父子类方法也不在存在多态的性质。

多态

实现多态的条件:

  1. 继承

子类可以继承父类的属性和方法,包括方法的参数、返回类型和方法名等。在子类中,我们可以对继承自父类的方法进行重写(覆盖),从而实现多态。

例如,假设有一个 Animal 类和一个 Dog 类,Dog 类继承自 Animal 类。在 Animal 类中定义了一个 makeSound() 方法,而在 Dog 类中覆盖了这个方法,实现了不同于 Animal 类中的 makeSound() 行为。当使用 Dog 对象执行 makeSound() 方法时,实际执行的是 Dog 类中的方法,表现出了多态的特性。

  1. 接口

接口可以看作是一种规范,它定义了一组规则,任何实现这个接口的类必须遵守这些规则。多态通过接口来实现,即一个接口可以由多个类实现,但是每个类实现的方式可能不同,因此表现出了多态特性。

例如,假设有一个接口 Shape,它定义了一个 calculateArea() 方法。Circle 和 Rectangle 类都实现了 Shape 接口,并分别实现了自己的 calculateArea() 方法。当我们需要计算一个图形的面积时,可以用 Shape 类型的变量接收 Circle 或 Rectangle 对象,调用 calculateArea() 方法时会根据对象的实际类型分别执行 Circle 或 Rectangle 类中的方法,这也是多态的表现。

  1. 重写

重写指的是子类对父类中已有的方法进行重新定义。在子类中实现和父类相同名称的方法时,子类可以将该方法重写(override)成自己特有的行为。当调用该方法时,实际执行的是子类中的方法,而不是父类中的方法,这也是多态的表现。

例如,假设有一个 Shape 类,它有一个 calculateArea() 方法。Rectangle 类继承自 Shape 类,并重写了 calculateArea() 方法,实现了自己特有的计算面积的算法。此时,当我们用 Shape 对象执行 calculateArea() 方法时,实际上可执行的是 Rectangle 中的方法,因为 Rectangle 对象是 Shape 的子类,它重写了 Shape 中的 calculateArea() 方法,从而实现了多态。

什么是多态

我们在编译期,只能调用父类声明的方法,但在执行期实际执行的是子类重写父类的方法。

重载,是指允许存在多个同名方法,而这些方法的参数不同。但是对于重载而言,在方法调用之前,编译器就已经确定了所要调用的方法,这称为“早绑定”或“静态绑定”。

而对于多态,只有等到方法调用的那一刻,解释运行器才会确定所要调用的具体方法,这称为“晚绑定”或“动态绑定”。它允许使用不同的方式实现同一方法名的操作。具体来说,多态指的是同一个父类引用指向不同的子类对象时,调用同一个方法,会产生不同的行为效果。

向上转型

  1. 本质:父类的引用指向子类的对象
  2. 作用:子类需要对父类中的一些方法进行重写,然后调用方法时就会调用子类重写的方法而不是原本父类的方法。向上转型后,子类单独定义的方法(重载)会丢失(即子类重载了父类中的方法),而子类中重写了父类的方法,当我们调用他们时,会调用重写的方法。
  3. 特点:编译类型看左边,运行类型看右边;可以调用父类的所有成员(需遵守访问权限);不能调用子类的特有成员;运行效果看子类的具体实现。

向下转型

  1. 本质:一个已经向上转型的子类对象,将父类引用转为子类引用
  2. 特点:只能强制转换父类的引用,不能强制转换父类的对象;要求父类的引用必须指向的是当前目标类型的对象;当向下转型后,可以调用子类类型中所有的成员

面试题

  1. 什么是Java中的封装(Encapsulation)?为什么要使用可见性修饰符?
  2. 什么是Java中的构造函数(Constructor)?它们的用途是什么?
  3. 什么是Java中的静态变量(Static variable)和静态方法(Static method)?
  4. 什么是Java中的内部类(Inner class)?请简述各种类型的内部类及其用途。
  5. 类之间的继承是如何工作的?Java中的继承有哪些限制?
  6. 抽象类和接口有什么区别?什么情况下最好使用抽象类和什么情况下使用接口?
  7. 什么是多态性?Java中的多态性是如何实现的?
  8. Java中的类和对象之间有什么区别?一个类可以实例化为多个对象,每个对象是如何使用类定义的?
  9. Java中的封装是如何使用的? Java的可见性修饰符有哪些?
  10. Java中的静态变量和实例变量之间有什么区别?
  11. 什么是类的构造函数?如何在Java类的构造函数中使用this关键字?
  12. 一个Java类可以继承多个类吗?一个类可以实现多个接口吗?
  13. 什么是内部类?Java中的内部类有哪些类型?内部类可以访问其外部类吗?

Java中的三大特性是什么,如何实现

  • 封装(Encapsulation)是指将类的属性和方法封装在类的内部,对外部隐藏实现细节,只提供公共的访问方式。封装可以保护类的数据不被随意修改或访问,提高代码的安全性和可维护性。在Java中,封装可以通过使用private、protected、public等访问修饰符来实现。
  • 继承(Inheritance)是指一个类可以继承另一个类的属性和方法,并在此基础上添加新的功能或重写原有的功能。继承可以实现代码的复用,提高编程效率和可扩展性。在Java中,继承可以通过使用extends关键字来实现。
  • 多态(Polymorphism)是指同一个对象在不同情境下表现出不同的行为或状态。多态可以实现程序的灵活性和通用性。在Java中,多态可以通过使用重载(overload)、重写(override)、接口(interface)、抽象类(abstract class)等方式来实现。

接口和抽象类

Java接口和抽象类都是用来实现多态性的。它们的主要区别在于实现方式。

接口:

  1. 接口只能包含抽象方法和常量,不能包含实例变量和构造方法;
  2. 接口中定义的方法都是公有的,不需要使用关键字public修饰;
  3. 一个类可以实现多个接口;
  4. 接口不能被实例化,需要通过实现接口的类来实例化。

抽象类:

  1. 抽象类可以包含抽象方法和非抽象方法,可以包含实例变量和构造方法;
  2. 抽象类中定义的方法可以有访问修饰符,可以是公有的、私有的、受保护的等;
  3. 一个类只能继承一个抽象类;
  4. 抽象类不能被实例化,需要通过继承抽象类的子类来实例化。

抽象类和接口是两种不同的抽象机制,它们有以下几点区别:

  • 抽象类可以有构造方法,接口中不能有构造方法。
  • 抽象类中可以有普通成员变量,接口中的成员变量只能是public static final类型的。
  • 抽象类中可以包含普通方法和抽象方法,接口中只能包含抽象方法,静态方法和默认方法(加default)。
  • 一个类只能继承一个抽象类,但是可以实现多个接口。
  • 抽象类是一种模板设计,体现的是"is-a"的关系;接口是一种行为规范,体现的是"like-a"的关系。

在使用上,建议:

  1. 当多个类实现同一组方法时,使用接口。实现某个接口的每个类都必须实现接口中定义的所有方法,因此可以确保方法的一致性。
  2. 当一组相关的类需要共享一些通用方法,并且这些方法有默认实现时,使用抽象类。抽象类可以提供默认实现,从而减少重复代码的编写。此外,抽象类还可以包含一些子类所需的变量和方法的实现细节,省去了时间和精力。
  3. 当一个类需要继承多个类时(Java不支持多重继承),可以使用接口。因为Java支持一个类实现多个接口,但只能继承一个类。

类的继承

类之间的继承是一种面向对象编程中的概念,它描述了一个类如何派生自另一个类并且继承其属性和方法。在继承中,派生类(也称为子类)使用已有的基类(也称为父类)的属性和方法,并且可以添加新的属性和方法以满足自己的需求。

在Java中,继承是通过使用关键字"extends"来实现的。子类使用extends关键字来指定它们需要继承的父类,并且可以通过访问修饰符来继承父类的属性和方法。

Java中的继承有以下限制:

  1. 单继承:Java不允许多重继承,即一个类只能继承一个父类。这是为了避免复杂性和混乱性,并提高代码的可读性和可维护性。
  2. 访问修饰符:子类只能继承父类中可以访问的属性和方法,也就是说,如果父类中的属性或方法被声明为private,则子类无法继承该属性或方法。
  3. 构造函数:子类必须调用其父类的构造函数来创建对象。这个调用必须是子类构造函数的第一条语句。
  4. 重写限制:子类方法可以重写(覆盖)父类方法,但是有一些限制。比如,子类的方法访问修饰符必须不低于父类方法,也就是说,子类重写的方法权限不能比父类更低。另外,父类中被final关键字修饰的方法不能被子类重写。
  5. 继承链:Java中的类可以形成一个继承链,即一个类可以继承自另一个类的子类,但是不允许循环继承,即一个类不允许直接或间接继承自它自己。

类的生命周期

在Java中,类的生命周期包括以下几个步骤:

  1. 加载(Loading):Java虚拟机首先需要找到并加载类的二进制字节码文件。这可以通过类加载器来完成。
  2. 验证(Verification):验证阶段用于确保加载的类满足Java虚拟机规范的要求。这个过程会对类的二进制格式进行检查,以确保它是有效并且不会导致安全问题。
  3. 准备(Preparation):在这一阶段,类加载器分配内存并初始化类的静态字段。这些静态字段在此阶段被分配默认值(0,null等)。
  4. 解析(Resolution):解析阶段用于将符号引用转换成直接引用,以确保类能够被正确链接和执行。这个过程包括符号引用和内存地址之间的映射。
  5. 初始化(Initialization):在这个阶段,静态字段被赋予程序员指定的值,并执行类构造函数。这个阶段是类生命周期中的关键点,因为它决定了类的实例是何时创建的。
  6. 使用(Usage):一旦完成初始化,类就可以被使用了。程序可以创建类的对象、调用类的方法或访问类的字段。
  7. 卸载(Unloading):如果Java虚拟机不再需要类,将卸载它。类卸载的条件包括没有任何实例被引用,并且类加载器已经被垃圾回收。

总之,类的生命周期包括从加载到卸载的过程,其中初始化是关键点。对于每个阶段,Java虚拟机会负责执行必要的任务,以确保类被正确加载、链接和执行。

Object 类

java.lang.Object类

  1. Object.类是所有Java类的根父类;

  2. 如果在类的声明中未使用extends关键字指明其父类,则默认父类为java.lang.Object类

  3. Object类中的功能(属性、方法)就具有通用性。

    equals(),toString(),getclass(),hashCode(),clone(),finalize(),wait(),notify(),notifyAll()

  4. Object类只声明了一个空参的构造器。

==和equals

==

  1. 可以使用在基本数据类型变量和引/用数据类型变量中

  2. 如果比较的是基本数据类型变量:比较两个变量保存的数据是否相等。(不一定类型要相同)

  3. 如果比较的是引用数据类型变量:比较两个对橡的地址值是否相同,即两个引用是否指向同一个对象实体

    补充:==符号使用时,必须保证符号左右两边的变量类型一致。

equals()

  1. 是一个方法,而非运算符
  2. 只能适用于引用数据类型,
  3. Object类中定义的equals()和==的作用是相同的,比较两个对象的地址值是否相同,即两个引用是否指向同一个对象实体。
  4. 像String、Date、File、包装类等都重写了Object类中的equals()方法,两个引用的地址是否相同,而是比较两个对象的**“实体内容”**是否相同。

toString()

  1. 当我们输出一个引用对象时,实际上就是调用当前对象的toString()
  2. Object类中toString的定义方法
  3. 像String、Date、File、包装类等都重写了Object类中的toString()方法。使得在调用toString()时,返回"实体内容"信息.

Static

当我们编写一个类时,其实就是在描述其对象的属性和行为,而并没有产生实质上的对象,只有通过new关键字才会产生出对象,这时
系统才会分配内存空间给对象,其方法才可以供外部调用。

我们有时候希望无论是否产生了对象或无论产生了多少对象的情况下,某些特定的数据在内存空间里只有一份。

注解

注解的作用

注解是JDK1.5版本开始引入的一个特性,用于对代码进行说明,可以对包、类、接口、字段、方法参数、局部变量等进行注解。它主要的作用有以下四方面:

  • 生成文档,通过代码里标识的元数据生成javadoc文档。
  • 编译检查,通过代码里标识的元数据让编译器在编译期间进行检查验证。
  • 编译时动态处理,编译时通过代码里标识的元数据动态处理,例如动态生成代码。
  • 运行时动态处理,运行时通过代码里标识的元数据动态处理,例如使用反射注入实例。

注解的常见分类

  • Java自带的标准注解,包括@Override、@Deprecated和@SuppressWarnings,分别用于标明重写某个方法、标明某个类或方法过时、标明要忽略的警告,用这些注解标明后编译器就会进行检查。
  • 元注解,元注解是用于定义注解的注解,包括@Retention、@Target、@Inherited、@Documented
    • @Retention用于标明注解被保留的阶段
    • @Target用于标明注解使用的范围
    • @Inherited用于标明注解可继承
    • @Documented用于标明是否生成javadoc文档
  • 自定义注解,可以根据自己的需求定义注解,并可用元注解对自定义注解进行注解。

注解处理器

注解处理器(Annotation Processor)是Java编译器内置的一个工具,主要用于在编译时处理Java源代码中的注解信息,从而动态生成一些额外的Java代码。

注解处理器的工作原理

  1. 定义注解:首先需要定义一个注解,它是一个被@Interface修饰的接口,其中可以定义各种属性和方法。
  2. 编写注解处理器:然后我们需要编写一个继承自javax.annotation.processing.AbstractProcessor类的注解处理器,这是整个注解处理器的核心部分,负责注解的扫描、处理和代码生成等任务。
  3. 配置处理器:接着我们需要创建一个META-INF/services/javax.annotation.processing.Processor文件,指定我们自己的注解处理器,告诉编译器我们要使用哪个注解处理器来处理注解。
  4. 编译代码:最后,我们需要编译Java源代码,编译器在编译期间检测到注解并且发现指定了注解处理器,就会把注解传递给该处理器进行处理,生成额外的Java代码。

注解处理器的使用方法

  1. 定义注解:首先需要定义一个注解,定义方式是使用@Interface关键字修饰的一个接口,在接口上可以定义各种属性和方法。
  2. 编写注解处理器:然后我们需要编写一个继承自javax.annotation.processing.AbstractProcessor类的注解处理器,这个类实现了javax.annotation.processing.Processor接口中的方法,主要包括以下几个核心方法:
    • init(ProcessingEnvironment env): 注解处理器初始化,在这里可以获取到编译环境,例如编译器的类型、日志位置、元素工具等。
    • process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv): 处理注解,这个方法会接收到所有被标注了注解的元素,例如类、字段、方法等,可以在这里对它们做一些处理,并且生成相关的Java代码。
    • getSupportedSourceVersion(): 获取支持的Java版本号。
    • getSupportedAnnotationTypes(): 获取支持的注解类型。
  3. 配置处理器:在META-INF/services/目录下创建javax.annotation.processing.Processor文件,写入我们自己的注解处理器类的全限定名。
  4. 编译Java代码:编译Java代码,编译器在编译期间会自动扫描并加载注解处理器,然后调用处理器中的process()方法处理注解,并生成相应的Java代码。最后将编译出的.class文件打包成jar包即可。

总之,注解处理器是一种通过预编译的方式为程序添加额外的功能、简化操作的工具,可以用于代码生成、性能优化、注入特性等众多场景。

元注解

元注解是指可以注解到其他注解上的注解,用于对其他注解进行说明和扩展。元注解就像是Java中的“Annotation的Annotation”。

在Java中,一共有5个元注解,分别是:

  1. @Retention:指定注解保留的生命周期,包括SOURCECLASSRUNTIME三个值。其中,SOURCE表示注解只在源代码中可用,编译后不会保留;CLASS表示注解在编译时保留,但在运行时不可用;RUNTIME表示注解在编译时保留,并且在运行时可用。
  2. @Target:指定注解的作用范围,包括ANNOTATION_TYPECONSTRUCTORFIELDLOCAL_VARIABLEMETHODPACKAGETYPE等值。
  3. @Documented:用于指定是否将注解信息加入到Java文档中。
  4. @Inherited:用于指定注解是否具有继承性,即如果一个类使用了具有@Inherited注解的父类注解,则其子类也会继承该注解。
  5. @Repeatable:Java8新增的元注解,用于支持可重复注解,即允许一个类或方法可以多次使用同一个注解。需要使用@Repeatable注解,并将其参数设置为包含相同注解的容器注解以上

5个元注解都属于Java.lang.annotation包中。它们各自负责对其他普通注解进行解释、说明和扩展,为注解的使用提供了非常重要的便捷功能。

保留范围

注解的保留范围指的是注解可以被应用的程序元素,例如类、方法、变量等。Java中提供了4种保留范围,分别是:

  1. @Target(ElementType.TYPE):表示注解可以应用于类、接口(包括注解类型)或枚举类型。
  2. @Target(ElementType.FIELD):表示注解可以应用于字段(包括枚举常量)。
  3. @Target(ElementType.METHOD):表示注解可以应用于方法。
  4. @Target(ElementType.PARAMETER):表示注解可以应用于参数。

生命周期

注解的生命周期指的是注解在Java虚拟机(JVM)中存在的时间,即注解保留的阶段。Java中提供了3种生命周期,分别是:

  1. @Retention(RetentionPolicy.SOURCE):表示注解仅保留在源代码中,在编译后就被丢弃。
  2. @Retention(RetentionPolicy.CLASS):表示注解被编译器保留,在class文件中存在,但在运行时被JVM丢弃,这也是默认生命周期。
  3. @Retention(RetentionPolicy.RUNTIME):表示注解被保留到运行时,在JVM加载类时仍然存在,可以通过反射获取。

自定义注解

自定义注解的步骤如下:

  1. 使用@Interface声明一个新的注解类型。例如:
@Target(ElementType.TYPE) // 该注解可以用于类、接口、枚举
@Retention(RetentionPolicy.RUNTIME) // 运行时保留该注解
public @interface MyAnnotation {
    int value();
}
  1. 定义自己需要的属性和方法。

    在上面的例子中,我们定义了一个名为“value”的属性,它可以使用int类型的值进行赋值。我们还可以添加其他类型的属性和方法。

  2. 使用自己定义的注解。

    在代码中使用自己定义的注解,以及传递给注解的值,例如:

@MyAnnotation(value = 10)
public class MyClass {
    // ...
}

在这个例子中,我们把“@MyAnnotation(value = 10)”添加到MyClass类之前,以告诉编译器这个类需要使用我们自己定义的注解,并传递一个整数值10给它。

最后,需要注意的是,在使用自定义注解时,我们需要通过反射机制才能读取注解信息,具体可以参考Java反射相关知识。除此之外,注解还有许多应用场景,例如JUnit框架就是使用注解来标记测试方法。

注解的扩展性

Java的注解机制是高度可扩展的。除了可以在程序中使用预定义的注解类型外,还可以自定义注解类型,并使用注解处理器来扩展注解的功能。

注解处理器是用于处理注解的工具,可以在编译时或运行时扫描和处理注解。它可以通过java.lang.reflect和javax.annotation.processing包中提供的API来实现。

例如,我们可以创建一个自定义注解@MyAnnotation,并定义它的处理器,如下所示:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.SOURCE)
public @interface MyAnnotation {
    // ...
}

@SupportedAnnotationTypes("MyAnnotation")
public class MyAnnotationProcessor extends AbstractProcessor {

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        // 处理注解的逻辑
        // ...
        return true;
    }

}

在这个例子中,我们定义了一个名为MyAnnotationProcessor的注解处理器,并使用@SupportedAnnotationTypes注解指定要处理的注解类型为MyAnnotation。在process方法中,我们可以实现我们想要的逻辑,例如扫描注解的位置、生成代码等。

接着,我们需要在编译器中配置该注解处理器,以便在编译过程中自动执行该处理器。可以通过以下步骤来配置:

  1. 创建一个名为META-INF/services/javax.annotation.processing.Processor的文件。

  2. 在该文件中,写入注解处理器的全限定类名,例如:

    com.example.MyAnnotationProcessor
    
  3. 将该文件打包到JAR文件中,并将JAR文件添加到编译路径中。

最后,在使用自定义注解时,我们只需要在注解上标注@MyAnnotation,并将对应的处理器配置到编译器中即可。当编译器检测到使用了@MyAnnotation注解时,将会自动调用该处理器进行处理。

需要注意的是,注解处理器的执行顺序是不确定的,因此处理器之间的交互需要谨慎处理。同时,注解处理器只能处理编译时注解,如果需要在运行时处理注解,需要使用反射机制。

异常

运行时异常

Java异常类层次结构

Throwable 是 Java 语言中所有错误与异常的超类。

  • Error 类及其子类:程序中无法处理的错误,表示运行应用程序中出现了严重的错误。
  • Exception 程序本身可以捕获并且可以处理的异常。Exception 这种异常又分为两类:运行时异常和编译时异常。
img
  • 运行时异常

    都是RuntimeException类及其子类异常,如NullPointerException(空指针异常)、IndexOutOfBoundsException(下标越界异常)等,这些异常是不检查异常,程序中可以选择捕获处理,也可以不处理。这些异常一般是由程序逻辑错误引起的,程序应该从逻辑角度尽可能避免这类异常的发生。

    运行时异常的特点是Java编译器不会检查它,也就是说,当程序中可能出现这类异常,即使没有用try-catch语句捕获它,也没有用throws子句声明抛出它,也会编译通过。

  • 非运行时异常 (编译异常)

    是RuntimeException以外的异常,类型上都属于Exception类及其子类。从程序语法角度讲是必须进行处理的异常,如果不处理,程序就不能编译通过。如IOException、SQLException等以及用户自定义的Exception异常,一般情况下不自定义检查异常。

可查异常和不可查异常

  • 可查异常(checked exceptions)(编译器要求必须处置的异常):

    正确的程序在运行中,很容易出现的、情理可容的异常状况。可查异常虽然是异常状况,但在一定程度上它的发生是可以预计的,而且一旦发生这种异常状况,就必须采取某种方式进行处理。

    除了RuntimeException及其子类以外,其他的Exception类及其子类都属于可查异常。这种异常的特点是Java编译器会检查它,也就是说,当程序中可能出现这类异常,要么用try-catch语句捕获它,要么用throws子句声明抛出它,否则编译不会通过。

  • 不可查异常(unchecked exceptions)(编译器不要求强制处置的异常)

    包括运行时异常(RuntimeException与其子类)和错误(Error)。

try-catch-finally

  • try
    • 捕获异常的第一步是用try{…}语句块选定捕获异常的范围,将可能出现异常的代码放在try语句块中。
  • catch(Exceptiontypee)
    • 在catch语句块中是对异常对象进行处理的代码。每个try语句块可以伴随一个或多个catch语句,用于处理可能产生的不同类型的异常对象。
    • 捕获异常的有关信息:与其它对象一样,可以访问一个异常对象的成员变量或调用它的方法。
    • getMessage() 获取异常信息,返回字符串
    • printStackTrace() 获取异常类名和异常信息,以及异常出现在程序中的位置。返回值void。
  • finally
    • 捕获异常的最后一步是通过finally语句为异常处理提供一个统一的出口,使得在控制流转到程序的其它部分以前,能够对程序的状态作统一的管理。
    • 不论在try代码块中是否发生了异常事件,catch语句是否执行,catch语句是否有异常,catch语句中是否有return,finally块中的语句都会被执行。像数据库连接、输入输出流、网络编程Socket等资源,JVM是不能自动的回收的,我们需要自己手动的进行资源的释放。此时的资源释放,就需要声明在finally中。
    • finally语句和catch语句是任选的

throw和throws

异常的申明(throws)

在Java中,当前执行的语句必属于某个方法,Java解释器调用main方法执行开始执行程序。若方法中存在检查异常,如果不对其捕获,那必须在方法头中显式声明该异常,以便于告知方法调用者此方法有异常,需要进行处理。 在方法中声明一个异常,方法头中使用关键字throws,后面接上要声明的异常。若声明多个异常,则使用逗号分割。

异常的抛出(throw)

如果代码可能会引发某种错误,可以创建一个合适的异常类实例并抛出它,这就是抛出异常。一旦当方法体执行时,出现异常,仍会在异常代码处生成一个异常类的对象,此对象满足throws后异常类型时,就会被抛出。异常代码后续的代码,就不再执行!

throws的方式只是将异常抛给了方法的调用者。 并没有真正将异常处理掉。

throw和try-catch

如果父类中被重写的方法没有throws方式处理异常,则子类重写的方法也不能使用throws,意味着如果子类重写的方法中有异常,必须使用try-catch-finally方式处理

执行的方法和中,先后又调用了另外的几个方法,这几个方法是递进关系执行的。我们建议这几个方法使用throws的方式进行处理。而执行的方法起可以考虑使用try-catch-finally方式进行处理。

异常的底层

异常表中包含了一个或多个异常处理者(Exception Handler)的信息,这些信息包含如下

  • from 可能发生异常的起始点
  • to 可能发生异常的结束点
  • target 上述from和to之前发生异常后的异常处理者的位置
  • type 异常处理者处理的异常的类信息

final、finally和finalize有什么区别?

final、finally和finalize是Java语言中三个不同的概念,具有不同的用途。

  1. final:final是Java中的关键字,可以用于修饰类、方法或变量。使用final修饰的类表示该类不能被继承,final修饰的方法表示该方法不能被覆盖,final修饰的变量表示该变量只能被赋值一次,即常量。
  2. finally:finally是Java中的关键字,用于定义一个代码块,无论是否捕获到异常,这个代码块都会被执行。通常用来做清理工作,比如关闭文件、释放数据库连接等。在try-catch语句中,finally语句块中的代码总是在try和catch语句块之后执行。
  3. finalize:finalize是一个Object类中的方法,用于在对象被垃圾回收器回收之前做一些清理工作。当一个对象已经没有被引用时,垃圾回收器会清除该对象并调用其finalize()方法。finalize()方法一般不建议程序员手动调用,因为它的调用时机不确定。

总的来说,final、finally和finalize是三种不同的Java关键字或方法,具有不同的用途。需要根据具体的需求来进行使用。

finally 中的代码一定会执行吗

另外,在以下 2 种特殊情况下,finally 块的代码也不会被执行:

  1. 程序所在的线程死亡。
  2. finally 之前虚拟机被终止运行
  3. 关闭 CPU。

异常使用有哪些需要注意的地方?

  • 不要把异常定义为静态变量,因为这样会导致异常栈信息错乱。每次手动抛出异常,我们都需要手动 new 一个异常对象抛出。
  • 抛出的异常信息一定要有意义。
  • 建议抛出更加具体的异常比如字符串转换为数字格式错误的时候应该抛出NumberFormatException而不是其父类IllegalArgumentException。
  • 使用日志打印异常之后就不要再抛出异常了(两者不要同时存在一段代码逻辑中)。

错误

异常和错误

Java中的异常(Exception)和错误(Error)都是属于Throwable类的子类,它们的主要区别在于产生原因和处理方式不同。

  1. 异常(Exception):异常指的是在程序运行过程中发生了某种不被期望的情况,如文件不存在、数字格式错误等,通常可以被程序本身处理或捕获。因此,Java处理异常的默认方式是中断处理,可以通过try-catch-finally语句或throws关键字将异常抛出。异常分为编译时异常和运行时异常两种类型。编译时异常在编译时就能够被捕获到,需要在代码中显式地进行处理;而运行时异常则表示程序本身无法处理的异常,在运行时才会被抛出。
  2. 错误(Error):错误指的是系统级的问题,通常是因为虚拟机或操作系统出现了无法恢复的情况,如内存不足、栈溢出等,这些问题超出了程序能力的范围,无法被程序本身处理。因此,Java对错误的处理方式是终止程序的运行。如果程序出现了错误,那么它就无法继续执行下去,只能退出程序并尝试重新启动。不建议通过catch捕获 。例如 Java 虚拟机运行错误(Virtual MachineError)、虚拟机内存不够错误(OutOfMemoryError)、类定义错误(NoClassDefFoundError)等 。这些异常发生时,Java 虚拟机(JVM)一般会选择线程终止。

总的来说,异常通常由程序员自己产生,是可以被程序本身捕捉和处理的;而错误通常由系统级问题引起,通常是无法被程序本身处理的,并且会导致程序崩溃。在实际开发中,应该尽可能避免出现错误和异常,保证程序的稳定性和可靠性。

Checked Exception 和 Unchecked Exception

Checked Exception 即 受检查异常 ,Java 代码在编译过程中,如果受检查异常没有被 catch或者throws 关键字处理的话,就没办法通过编译。

除了RuntimeException及其子类以外,其他的Exception类及其子类都属于受检查异常 。常见的受检查异常有: IO 相关的异常、ClassNotFoundException 、SQLException…。

Unchecked Exception 即 不受检查异常 ,Java 代码在编译过程中 ,我们即使不处理不受检查异常也可以正常通过编译。

RuntimeException 及其子类都统称为非受检查异常,常见的有(建议记下来,日常开发中会经常用到):

  • NullPointerException(空指针错误)
  • IllegalArgumentException(参数错误比如方法入参类型错误)
  • NumberFormatException(字符串转换为数字格式错误,IllegalArgumentException的子类)
  • ArrayIndexOutOfBoundsException(数组越界错误)
  • ClassCastException(类型转换错误)
  • ArithmeticException(算术错误)
  • SecurityException (安全错误比如权限不够)
  • UnsupportedOperationException(不支持的操作错误比如重复创建同一用户)

设计模式

程序设计原则

SOLID是一组面向对象编程中的五个设计原则,这些原则旨在提高软件系统的可维护性、可扩展性和可重用性。SOLID原则包括以下五个单独的原则:

  1. SRP原则 (Single Responsibility Principle):单一责任原则,一个类应该只有一个引起它变化的原因,也就是说一个类只负责一项职责。比如订单类只负责生成订单、保存订单,而不负责发送邮件或者短信提醒。
  2. OCP原则 (Open-Closed Principle):开放-封闭原则,对扩展开放,对修改封闭。指当软件变化时,应当通过增加新的代码来扩展软件的行为,而不是修改现有的代码。
  3. LSP原则 (Liskov Substitution Principle):里氏替换原则,子类型必须能够替换掉它们的父类型。父类对象可以被子类对象替换,并且程序功能不受影响。
  4. ISP原则 (Interface Segregation Principle):接口隔离原则,客户端不应该强制依赖它们不使用的方法。也就是说应该将客户端所需的方法定义在专门的接口中,而不是强制客户端去实现那些它们不需要的方法。
  5. DIP原则 (Dependency Inversion Principle):依赖倒置原则,高层次的模块不应该依赖于低层次的模块,而是应该源于抽象类或者接口,具体类依赖于抽象类或者接口。比如,在一个实现依赖注入的系统中,高层次的模块只需要声明它所需要的依赖项,而不需要关心具体的实现,这样就可以有效地降低耦合度。

单例设计模式

定义和特点

单例(Singleton)模式的定义:指一个类只有一个实例,且该类能自行创建这个实例的一种模式。例如,Windows 中只能打开一个任务管理器,这样可以避免因打开多个任务管理器窗口而造成内存资源的浪费,或出现各个窗口显示内容的不一致等错误。

在计算机系统中,还有 Windows 的回收站、操作系统中的文件系统、多线程中的线程池、显卡的驱动程序对象、打印机的后台处理服务、应用程序的日志对象、数据库的连接池、网站的计数器、Web 应用的配置对象、应用程序中的对话框、系统中的缓存等常常被设计成单例。

  1. 单例类只有一个实例对象;
  2. 该单例对象必须由单例类自行创建;
  3. 单例类对外提供一个访问该单例的全局访问点;

结构与实现

  1. 懒汉式单例

    该模式的特点是类加载时没有生成单例,只有当第一次调用 getlnstance 方法时才去创建这个单例。如果编写的是多线程程序,则要使用关键字 volatile 和 synchronized,否则将存在线程非安全的问题。但是使用这两个关键字每次访问时都要同步,会影响性能,且消耗更多的资源,这是懒汉式单例的缺点。

    使用双检索的方式创建线程安全的单例模式 Java 代码:

    public class Singleton {
        private volatile static Singleton instance;
    
        private Singleton() {}
    
        public static Singleton getInstance() {
            if (instance == null) {//防止不必要的加锁
                synchronized (Singleton.class) {
                    if (instance == null) {
                        instance = new Singleton();
                    }
                }
            }
            return instance;
        }
    }
    

    通过使用 volatile 关键字,保证了对 instance 变量的修改对所有线程的可见性。并且在 synchronized 块内部进行双检索,确保了在多线程的情况下只有一个实例被创建,并且保证了线程的安全性。

    需要注意的是,使用双检索仍然存在一定的风险,因为JVM有可能会对指令进行重排序,使用双检索可能会导致实例先被分配内存,而后实例化,此时其他线程可以使用未实例化的实例。此问题可以通过将 instance 变量声明为 volatile 来解决。

  2. 饿汉式单例

    该模式的特点是类一旦加载就创建一个单例,保证在调用 getInstance 方法之前单例已经存在了。饿汉式单例在类创建的同时就已经创建好一个静态的对象供系统使用,以后不再改变,所以是线程安全的,可以直接用于多线程而不会出现问题。

    public class Singleton {
        // 类加载时就创建实例
        private static Singleton instance = new Singleton();
     
        // 私有化构造方法,限制外部类创建实例
        private Singleton() {
        }
     
        // 提供访问实例的静态方法
        public static synchronized Singleton getInstance() {
            return instance;
        }
    }
    

    使用饿汉式单例时,可以通过Singleton.getInstance()来获取单例对象。由于饿汉式单例在类加载时就创建实例,因此可以保证在程序运行期间只有一个实例对象。注意,在多线程环境下,需要注意实例的并发访问问题,可以考虑在getInstance()方法上加上同步锁来保证线程安全。

应用场景

  1. 需要频繁创建和销毁的对象:由于创建和销毁对象会涉及一些繁琐的操作,而单例模式可以避免不必要的操作,提高程序的性能和效率。
  2. 资源共享的情况:当多个对象需要共享同一个资源时,使用单例模式可以避免资源的重复创建和资源占用过多的问题。
  3. 控制实例数量的情况:在一些需要控制实例数量的场合,例如数据库连接池,线程池等,可以使用单例模式来保证只有固定数量的实例被创建和使用。这可以避免资源的浪费和性能的降低。
  4. 全局控制入口的情况:在一些中间件系统中,例如日志处理器、配置管理器等,需要通过一个中心化的控制入口来进行管理和维护。这种情况下,可以使用单例模式来实现控制入口。

代理模式 (Proxy)

定义

**概述:**为某对象提供一种代理以控制对该对象的访问。即客户端通过代理间接地访问该对象,从而限制、增强或修改该对象的一些特性。

定义:由于某些原因需要给某对象提供一个代理以控制对该对象的访问。这时,访问对象不适合或者不能直接引用目标对象,代理对象作为访问对象和目标对象之间的中介。

优点:

  • 可以使真实角色的操作更加纯粹,不用去关注一些公共的业务
  • 公共也就交给代理角色,实现了业务的分工
  • 公共业务发生扩展的时候,方便集中管理

缺点:

  • 代理模式会造成系统设计中类的数量增加
  • 在客户端和目标对象之间增加一个代理对象,会造成请求处理速度变慢
  • 增加了系统的复杂度

静态代理

概述:由程序员创建代理类或特定工具自动生成源代码再对其编译,在程序运行前代理类的 .class 文件就已经存在了。所谓静态也就是在程序运行前就已经存在代理类的字节码文件,代理类和真实主题角色的关系在运行前就确定了。

角色分析:

  • 抽象角色:一般会使用接口或抽象类来解决
  • 真实角色:被代理的角色
  • 代理角色:代理真实角色,代理真实角色后 一般会做一些附属操作
  • 客户:访问代理对象的人

虽然静态代理实现简单,且不侵入原代码,但是,当场景稍微复杂一些的时候,静态代理的缺点也会暴露出来。

1、 当需要代理多个类的时候,由于代理对象要实现与目标对象一致的接口,有两种方式:

  • 只维护一个代理类,由这个代理类实现多个接口,但是这样就导致代理类过于庞大
  • 新建多个代理类,每个目标对象对应一个代理类,但是这样会产生过多的代理类

2、 当接口需要增加、删除、修改方法的时候,目标对象与代理类都要同时修改,不易维护。

动态代理

动态代理是一种常见的设计模式,它是指在运行时动态地创建一个代理对象,用于替代实际的对象。动态代理通常使用反射机制来生成代理对象,所以也被称为反射代理。动态代理可以帮助我们在不修改原有代码的情况下,往方法调用前后添加一些额外的功能,比如记录日志、验证权限等。它可以把这些额外的功能从业务逻辑中分离出来,使得代码更加清晰、易于维护。

在Java中,动态代理通常有两种实现方式:JDK原生动态代理和CGLIB动态代理。JDK原生动态代理只能代理接口,而CGLIB动态代理可以代理类。除此之外,还有其他的动态代理框架,如JavaAssist、ASM等。

JDK原生动态代理使用了java.lang.reflect.Proxy类,该类提供了newProxyInstance()方法,通过该方法可以创建一个代理对象。在创建代理对象时,需要提供一个InvocationHandler对象,该对象负责处理代理对象的方法调用。JDK 动态代理只需要实现 InvocationHandler 接口,重写 invoke 方法便可以完成代理的实现,jdk的代理是利用反射生成代理类 Proxyxx.class 代理类字节码,并生成对象。jdk动态代理之所以只能代理接口是因为代理类本身已经extends了Proxy,而java是不允许多重继承的,但是允许实现多个接口。

由于 JDK 动态代理限制了只能基于接口设计,而对于没有接口的情况,JDK方式解决不了;CGLib 采用了非常底层的字节码技术,其原理是通过字节码技术为一个类创建子类,并在子类中采用方法拦截的技术拦截所有父类方法的调用,顺势织入横切逻辑,来完成动态代理的实现。CGLIB动态代理使用了net.sf.cglib.proxy.Enhancer类,该类与原对象具有相同的类结构,因此实现起来较为方便。CGLIB动态代理会在运行时扩展字节码,并生成代理类的实例。它也需要提供一个MethodInterceptor对象,用于处理代理对象的方法调用。但是CGLib在创建代理对象时所花费的时间却比JDK多得多,所以对于单例的对象,因为无需频繁创建对象,用CGLib合适,反之,使用JDK方式要更为合适一些。 同时,由于CGLib由于是采用动态创建子类的方法,对于final方法,无法进行代理。

**概述:**在程序运行时,运用反射机制动态创建而成。

特点:

  • 动态代理和静态代理角色一样
  • 动态代理的代理类是动态生成的,不是我们直接写好的
  • 动态代理分为两大类:基于接口的动态代理、基于类的动态代理

需要了解两个类:Proxy 代理,InvocationHandler 调用处理程序

使用场景

  • 安全代理:屏蔽对真实角色的直接访问。
  • 远程代理:通过代理类处理远程方法调用(RMI)
  • 延迟加载:先加载轻量级的代理对象,真正需要再加载真实对象

工厂模式

介绍

**意图:**定义一个创建对象的接口,让其子类自己决定实例化哪一个工厂类,工厂模式使其创建过程延迟到子类进行。

**主要解决:**主要解决接口选择的问题。

**何时使用:**我们明确地计划不同条件下创建不同实例时。

**如何解决:**让其子类实现工厂接口,返回的也是一个抽象的产品。

**关键代码:**创建过程在其子类执行。

应用实例: 1、您需要一辆汽车,可以直接从工厂里面提货,而不用去管这辆汽车是怎么做出来的,以及这个汽车里面的具体实现。 2、Hibernate 换数据库只需换方言和驱动就可以。

优点: 1、一个调用者想创建一个对象,只要知道其名称就可以了。 2、扩展性高,如果想增加一个产品,只要扩展一个工厂类就可以。 3、屏蔽产品的具体实现,调用者只关心产品的接口。

**缺点:**每次增加一个产品时,都需要增加一个具体类和对象实现工厂,使得系统中类的个数成倍增加,在一定程度上增加了系统的复杂度,同时也增加了系统具体类的依赖。这并不是什么好事。

使用场景: 1、日志记录器:记录可能记录到本地硬盘、系统事件、远程服务器等,用户可以选择记录日志到什么地方。 2、数据库访问,当用户不知道最后系统采用哪一类数据库,以及数据库可能有变化时。 3、设计一个连接服务器的框架,需要三个协议, “POP3”、 “IMAP”、 “HTTP”,可以把这三个作为产品类,共同实现一个接口。

**注意事项:**作为一种创建类模式,在任何需要生成复杂对象的地方,都可以使用工厂方法模式。有一点需要注意的地方就是复杂对象适合使用工厂模式,而简单对象,特别是只需要通过 new 就可以完成创建的对象,无需使用工厂模式。如果使用工厂模式,就需要引入一个工厂类,会增加系统的复杂度。

下面是一个简单的Java工厂模式的例子:

首先定义一个接口,这里我们定义了一个抽象的电脑接口:

public interface Computer {
    void printComputer();
}

然后实现两个不同的电脑类,分别为Macbook和Surface:

public class Macbook implements Computer {
    @Override
    public void printComputer() {
        System.out.println("This is a Macbook.");
    }
}

public class Surface implements Computer {
    @Override
    public void printComputer() {
        System.out.println("This is a Surface.");
    }
}

接下来是我们的工厂类,根据参数不同返回不同的电脑类:

public class ComputerFactory {
    public Computer createComputer(String type) {
        Computer computer = null;
        switch (type) {
            case "Macbook":
                computer = new Macbook();
                break;
            case "Surface":
                computer = new Surface();
                break;
        }
        return computer;
    }
}

最后,在main方法中调用工厂类的方法,即可创建不同的电脑对象:

public static void main(String[] args) {
    ComputerFactory factory = new ComputerFactory();  
    Computer macbook = factory.createComputer("Macbook");
    Computer surface = factory.createComputer("Surface");
    macbook.printComputer();
    surface.printComputer();
}

观察者模式

观察者模式是一种常用的设计模式,又叫做发布/订阅模式。它定义了一种一对多的依赖关系,让多个观察者对象同时监听某一个主题对象,当主题对象的状态发生变化时,它的所有观察者都会收到通知并进行相应的处理。

该模式中有两个角色:观察者和主题。观察者需要实现一个update()方法,在主题状态变更时被调用;而主题则需要维护一个观察者列表,并提供方法添加和删除观察者对象,同时在状态改变时通知所有观察者。

在实际应用中,观察者模式可以帮助我们实现对象之间的松散耦合,提高程序的可扩展性和可维护性。例如:当一个网站上线后,需要根据用户的浏览行为进行个性化推荐,此时可以将用户浏览信息作为主题,推荐系统作为观察者,当用户浏览行为发生变化时,观察者可以及时更新推荐内容。

在Java中,可以通过使用内置的Observable实现观察者模式,同时也需要定义Observer接口作为观察者的抽象类。接下来我们将给出一个简单的例子。

import java.util.Observable;
import java.util.Observer;

// 被观察者
class Subject extends Observable {
    private int state = 0; // 状态

    public int getState() {
        return state;
    }

    public void setState(int s) {
        state = s;
        setChanged(); // 设置变化点
        notifyObservers(); // 通知观察者
    }
}

// 观察者1
class Observer1 implements Observer {
    public void update(Observable obj, Object arg) {
        System.out.println("Observer1 has been notified. " + ((Subject) obj).getState());
    }
}

// 观察者2
class Observer2 implements Observer {
    public void update(Observable obj, Object arg) {
        System.out.println("Observer2 has been notified. " + ((Subject) obj).getState());
    }
}

public class ObserverPatternDemo {
    public static void main(String[] args) {
        Subject sub = new Subject();
        Observer ob1 = new Observer1();
        Observer ob2 = new Observer2();

        sub.addObserver(ob1);
        sub.addObserver(ob2);

        sub.setState(1); // 修改状态
        sub.deleteObserver(ob2);
        sub.setState(2); // 修改状态
    }
}

在上述的代码中,Subject类是被观察者,具有状态state,并且当状态发生变化时会通知所有观察者。Observer1和Observer2是观察者,每个观察者只需要实现update方法,即被通知时所需要采取的行动即可。

在main函数中,我们首先创建了一个Subject对象sub,然后创建了两个Observer对象ob1和ob2。接下来,我们使用addObserver方法向Subject对象注册观察者ob1和ob2,同时修改了Subject对象的状态state。第二次使用setState方法时,我们从Subject对象中删除了观察者ob2,因此只有ob1会被通知到state的变化。

MVC设计模式

MVC的全名是Model View Controller,是模型(Model)-视图(view)-控制器(controller)的缩写,是一种设计模式。它是用一种业务逻辑、数据与界面显示分离的方法来组织代码,将众多的业务逻辑聚集到一个部件里面,在需要改进和个性化定制界面及用户交互的同时,不需要重新编写业务逻辑,达到减少编码的时间,提高代码复用性。

  • model:模型持有所有的数据、状态和程序逻辑,在MVC的三个部件中,模型拥有最多的处理任务。被模型返回的数据是中立的,模型与数据格式无关,这样一个模型能为多个视图提供数据,由于应用于模型的代码只需写一次就可以被多个视图重用,所以减少了代码的重复性。
  • view:用来呈现模型,比如由html元素组成的网页界面,或者软件的客户端界面。MVC的好处之一在于它能为应用程序处理很多不同的视图。在视图中其实没有真正的处理发生,它只是作为一种输出数据并允许用户操纵的方式。
  • controller:指控制器接受用户的输入并调用模型和视图去完成用户的需求,控制器本身不输出任何东西和做任何处理。它只是接收请求并决定调用哪个模型构件去处理请求,然后再确定用哪个视图来显示返回的数据。(controller并不负责处理逻辑,只是将用户的输入解读为对模型的操作,所有的逻辑和对数据的操作都交给model)

java高级开发

泛型

泛型(Generics)是Java SE5中引入的一种编程机制,它提供了编译时类型检查和更好的类型安全性,可以让程序员编写更加通用和可重用的代码。在使用泛型时,将数据类型参数化,并作为一个参数传递给类、接口、方法等,这样就可以在不同的上下文中使用相同的代码,而不需要进行强制类型转换。

泛型的主要优点有:

  1. 提高类型安全性:泛型可以在编译时检测类型安全,在运行时可以避免类型转换错误、空指针异常等问题,减少了运行时错误的出现概率。
  2. 提高代码可读性:泛型可以使代码更加简洁、通用,也更加易于理解和维护。
  3. 提高代码重用性:泛型可以使代码更加通用,可以在不同的上下文中使用相同的代码。

泛型的一些限制包括:

  1. 类型参数只能是类类型(包括自定义类),不能是简单类型。
  2. 同一种泛型可以对应多个版本(因为参数类型是不确定的),不同版本的泛型类实例是不兼容的。
  3. 泛型的类型参数可以有多个。
  4. 泛型的参数类型可以使用extends语句,例如T extends superclass。习惯上成为有界类型。
  5. 泛型的参数类型还可以是通配符类型。例如Class<?> classType = Class.forName(“java.lang.String”)。

在使用泛型时,需要注意其规则和限制,以避免出现编译时或者运行时的错误问题。

泛型擦除

泛型擦除是指在编译期间,Java编译器将泛型类型的信息擦除,将泛型类型替换为它们的原始类型(如 Object、Integer 等),以便运行时能够正常地处理代码。

具体来说,在编译代码时,Java编译器会将使用泛型的代码转换成与泛型无关的代码。例如,一个 ArrayList<String> 类型的对象在编译后会变成一个 ArrayList 类型的对象。这就是所谓的类型擦除。

这种类型擦除带来的问题有:

  1. 不能使用基本类型作为类型参数,因为类型擦除会将其转换为对应的包装类型。
  2. 无法在运行时获取泛型类型的具体信息。
  3. 泛型类中的静态上下文无法访问泛型类型的参数,因为静态上下文在编译时就已经确定了,而泛型类型的参数只有在运行时才能获得。

Java I/O流

BIO、NIO、AIO

BIO、NIO、AIO是Java中不同的I/O模型,它们都提供了一套用于处理输入输出操作的API接口,但适用场景和性能特点不同。

  1. BIO

BIO(Blocking I/O)是Java中最早提供的I/O模型,也称为同步阻塞式I/O。在BIO模型中,每个I/O操作都会阻塞当前线程,直到该操作完成才能进行下一步操作,因此它只适合短连接的小规模网络编程。BIO模型采用一个线程对应一个客户端连接的方式,因此并发性能较差,容易导致系统资源的浪费。

  1. NIO

NIO(Non-Blocking I/O)是Java中提供的新型I/O模型,也称为同步非阻塞式I/O。在NIO模型中,采用了统一的事件驱动机制,当一个连接有数据可以读或者写时,NIO会发出通知,使得程序可以进行读或写操作,而不需要阻塞等待。因此,NIO适合处理大量连接的高并发网络编程。NIO模型采用一个线程管理多个客户端连接,通过轮询的方式实现高并发处理,提高系统的资源利用率。NIO的主要组成部分是通道(Channel)和缓冲区(Buffer),其实NIO就是基于块(Block)或缓冲区(Buffer)操作的,通过把数据放入缓冲区中,实现多路复用器选择某个通道进行数据传输(先将所有需要传输的数据缓存在本地内存中,再统一进行传输),可以大大提高程序的运行效率和可靠性。

  1. AIO

AIO(Asynchronous I/O)是Java中提供的最新的I/O模型,也称为异步非阻塞式I/O。在AIO模型中,用户线程不需要进行阻塞等待,而是注册一个回调函数,当操作完成时,系统将自动触发回调。AIO适合处理I/O操作耗时较长的情况,如文件传输、网络编程等。AIO模型采用了事件驱动机制,当操作完成时,系统自动触发回调,不存在轮询线程和状态查询,因此可以实现更加高效的I/O处理。

本质上,三种模型都是基于I/O多路复用机制来实现的,其中BIO模型是单线程同步阻塞I/O,NIO模型是多路复用非阻塞I/O,AIO模型是异步非阻塞I/O,它们各自的特点和适用场景不同,需要根据实际情况选择合适的I/O模型。

反射和动态代理

反射机制概述

Reflection(反射)是被视为动态语言的关键,反射机制允许程序在执行期借助于Reflection API取得任何类的内部信息,并能直接操作任意对象的内部属性及方法。

加载完类之后,在堆内存的方法区中就产生了一个Class类型的对象(一个类只有一个Class对象),这个对象就包含了完整的类的结构信息。我们可以通过这个对象看到类的结构。这个对象就像一面镜子,透过这个镜子看到类的结构,所以,我们形象的称之为:反射。我们可以使用Class类获取任意一个类的信息,使用Constructor类动态地创建该类的实例,使用Field类获取或设置该类的成员变量值,使用Method类动态地调用该类的方法。

  • Java反射机制提供的功能
    • 在运行时判断任意一个对象所属的类
    • 在运行时构造任意一个类的对象
    • 在运行时判断任意一个类所具有的成员变量和方法
    • 在运行时获取泛型信息
    • 在运行时调用任意一个对象的成员变量和方法
    • 在运行时处理注解
    • 生成动态代理
  • 反射相关的主要API
    • java.lang.Class:代表一个类
    • java.lang.reflect.Method:代表类的方法
    • java.lang.reflect.Field:代表类的成员变量
    • java.lang.reflect.Constructor:代表类的构造器

private反射

通过Java的反射机制,我们可以获取和操作类、对象、方法、属性等信息。对于一个private类型的变量,如果要获取或修改其值,需要先通过反射获取到该变量,并设置其访问权限为可访问(即将其打破)。我们需要通过调用setAccessible方法将其访问权限打破,这样才可以获取和修改其值。需要注意的是,使用反射来访问private变量可能会破坏程序的安全性和稳定性,因此应该谨慎使用。

  1. 获取Class对象,可以通过Class.forName()或者对象的getClass()方法获取;
  2. 获取Field对象,可以通过Class.getDeclaredField()方法获取;
  3. 调用setAccessible(true)方法打破封装性;
  4. 使用Field对象的get()或set()方法访问或修改变量的值。

获取Class对象

获取Class对象的方式有以下几种:

  1. 类名.class:通过类名后面加上.class的方式获取,例如Person.class。
  2. Class.forName():通过Class类的静态方法forName()获取,需要传入完整的包名和类名,例如Class.forName(“com.example.Person”)。
  3. 对象.getClass():通过已经存在的对象的getClass()方法获取,例如person.getClass()。
  4. 类加载器ClassLoader.loadClass():通过ClassLoader类中的loadClass()方法获取,需要传入完整的包名和类名,例如getClassLoader().loadClass(“com.example.Person”)。

以上四种方式都可以获得Class类型的对象,使我们可以在编译期不知道具体类名的情况下,动态地创建该类的对象或者调用该类的静态方法。

获取Field对象

在Java反射中,Field对象是用来表示类中的成员变量的。它提供了获取和设置成员变量值的方法,可以访问公共、私有和受保护的成员变量,并且可以获取到成员变量的名称、类型和修饰符等信息。

Field对象通常使用 Class 类中的 getField() 或 getDeclaredField() 方法获取,getField() 方法只能获取公共成员变量,而 getDeclaredField() 方法可以获取所有声明的成员变量,包括私有和受保护的成员变量。需要注意的是,如果要获取私有成员变量,必须将其访问权限设置为可访问状态。

Field对象 提供了以下常用的方法:

  • getName():获取成员变量的名称。
  • getType():获取成员变量的类型。
  • getModifiers():获取成员变量的修饰符。
  • setAccessible(true/false):设置成员变量是否可访问。
  • get(Object obj):获取指定对象的成员变量的值。
  • set(Object obj, Object value):设置指定对象的成员变量的值为 value。

获取Field对象的方式有以下几种:

  1. getDeclaredField()方法:该方法在Class类中定义,可以根据字段名称获取类中的特定字段,包括私有、受保护和公共字段。示例代码如下:
Class<?> clazz = Person.class;
Field field = clazz.getDeclaredField("name");
  1. getField()方法:该方法在Class类中定义,可以获取指定名称、修饰符为public的公共字段,如果需要获取私有或受保护的字段,需要使用getDeclaredField()方法。示例代码如下:
Class<?> clazz = Person.class;
Field[] fields = clazz.getFields();
for (Field field : fields) {
    System.out.println(field.getName());
}

上述代码会获取Person类中所有public修饰的字段。

当获取到Field对象后,可以通过反射机制对字段进行操作,例如获取和设置字段值。

方法调用

在Java反射中,方法调用是指通过Method类的 invoke() 方法来执行一个方法。在 Method 对象中有一个 invoke() 方法,该方法可以调用目标方法并返回其结果。

使用反射调用方法的具体步骤如下:

  1. 获取Class对象:首先需要获取目标类的 Class 对象,可以通过类名、对象、全限定名等方式获取。
  2. 获取Method对象:通过 Class 类中的 getDeclaredMethod() 或 getMethod() 方法,根据方法名和参数类型获取要调用的方法的 Method 对象。
  3. 设置访问权限:如果要调用的方法是私有方法,则需要先将其设置为可访问状态,可以通过 setAccessible(true) 方法来实现。
  4. 调用方法:通过 invoke() 方法执行获取到的 Method 对象,传入方法所在的对象及方法参数,即可调用方法并返回结果。

动态语言和静态语言

  • 动态语言:是一类在运行时可以改变其结构的语言:例如新的函数、对象、甚至代码可以被引进,已有的函数可以被删除或是其他结构上的变化。通俗点说就是在运行时代码可以根据某些条件改变自身结构。主要动态语言:Object-C、C#、JavaScript、PHP、Python、Erlang。
  • 与动态语言相对应的,运行时结构不可变的语言就是静态语言。如Java、C、C++。
    • Java不是动态语言,但Java可以称之为“准动态语言”。即Java有一定的动态性,我们可以利用反射机制、字节码操作获得类似动态语言的特性。Java的动态性让编程的时候更加灵活!

字节码增强技术

字节码增强技术是指一类对现有字节码进行修改或者动态生成全新字节码文件的技术,通过修改字节码可以实现类似于 AOP(面向切面编程)这样的功能。常用的字节码增强技术有 ASM、Javassist 和 ByteBuddy 等。

通常情况下,字节码增强技术被广泛应用于以下场景:

  1. AOP:AOP 利用了字节码增强技术,在目标方法执行前后插入相应的通知逻辑,从而实现类似于日志记录、权限校验等横切关注点的功能。
  2. 数据库访问框架:很多 ORM 框架(例如 Hibernate)使用字节码增强技术来实现数据的快速映射和访问。
  3. Swagger API 文档自动生成:在构建 RESTful API 时,往往需要编写大量的 API 文档,使用字节码增强技术可以在源代码编译时自动生成相应的文档信息。
  4. 单元测试和集成测试:在单元测试和集成测试中,使用字节码增强技术可以模拟出一些复杂的场景和环境,从而更好地进行测试和调试。

AspectJ

AspectJ 是一种 Java 字节码增强框架,它能够在 Java 代码编译期间将切面代码注入到 Java 类中,从而实现 AOP 的功能。其实现原理可以简述为以下三个步骤:

  1. 定义切入点:在源代码中使用 AspectJ 提供的注解定义切入点,表明需要对哪些类、方法、属性进行切面处理。
  2. 编写切面代码:使用 AspectJ 提供的语法编写切面代码,用于在指定的切入点处进行增强操作。例如,可以在某个方法执行前打印日志,或者在方法执行后记录返回值等。
  3. 编译为字节码:使用 AspectJ 编译器将带有切面注解的源代码编译成字节码文件,并生成一个新的类文件。在这个过程中,AspectJ 编译器会根据切面注解和切入点信息,将切面代码嵌入到目标类的字节码中。

在运行时,当目标类的方法被调用时,JVM 会自动执行嵌入其中的切面代码,从而实现了在不修改源代码的前提下,在程序运行时动态地增强目标类的功能。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值