Skip to content

Java 面试题

更新: 8/28/2025 字数: 0 字 时长: 0 分钟

接口和抽象类有什么区别?

在 Java 中,接口和抽象类都是实现抽象的机制,但它们在设计动机、功能和使用方式上存在显著差异。

设计动机和理念:

  1. 接口的设计是自顶向下的:

    • 理念:我们首先从高层次考虑“某种行为规范”或“契约”。我们预先知道或约定某一组行为(即“能力”),然后基于这些行为来定义接口。任何需要具备这些行为的类,就去实现对应的接口。
    • 思考过程:“我需要一个能跑(Runnable)的对象”、“我需要一个能比较(Comparable)的对象”、“我需要一个能连接数据库(Connection)的对象”。我们先定义这个“能做什么”的规范,再去考虑“谁来实现”以及“怎么实现”。
    • 目的:主要用于定义行为契约,实现多态,解决多重继承问题,强调能力
  2. 抽象类的设计是自底向上的:

    • 理念:我们通常是先写了许多具体的类,在这些类的开发过程中,我们发现它们之间存在共同的属性和行为,有很多代码是可以复用的。为了消除代码冗余、提高代码复用性并提供一个统一的父类模板,我们将这些公共的逻辑和成员抽象封装到一个抽象类中。
    • 思考过程:“我有很多形状类(Circle, Rectangle, Triangle),它们都有计算面积的方法,也有共同的颜色属性。我可以把这些共同的东西抽象成一个 AbstractShape 类。”
    • 目的:主要用于提供一个通用实现模板,共享代码,实现部分功能,并强制子类完成未实现的抽象部分,强调父子关系。在实际项目开发中,很多时候抽象类是重构的产物。

总结自顶向下与自底向上:

  • 自顶向下:先约定接口(规范),再由不同的类去实现它。关注点在“能做什么”。
  • 自底向上:先有一些具体的类,发现共性后,将共性抽象成一个父类(抽象类)。关注点在“是什么”和“共同之处”。

其他主要区别:

除了设计理念上的差异,还有以下几个关键的技术区别:

  1. 方法实现:

    • 接口:
      • 在 Java 8 之前,接口中的方法默认是 public abstract,不允许有方法实现。
      • 从 Java 8 开始,接口可以包含 default 方法(有默认实现)和 static 方法(静态实现)。
      • Java 9 以后还允许 private 方法和 private static 方法。
    • 抽象类
      • 抽象类可以包含 abstract 方法(没有实现,必须由子类实现)和具体方法(有实现,子类可以直接继承或覆盖)。
      • 允许子类继承并重用抽象类中的方法实现。
  2. 构造函数和成员变量:

    • 接口:
      • 接口不能包含构造函数。
      • 接口中的成员变量默认是 public static final,即常量。
    • 抽象类:
      • 抽象类可以包含构造函数。虽然抽象类不能直接实例化,但它的构造函数会在子类实例化时被调用,用于初始化抽象类中定义的成员变量。
      • 成员变量可以有不同的访问修饰符(如 private, protected, public),并且可以不是常量。
  3. 多重继承:

    • 接口:一个类可以实现多个接口。这是 Java 实现多重行为(多重实现)的关键机制,因为 Java 不支持类的多重继承。
    • 抽象类:一个类只能继承一个抽象类(遵循 Java 的单继承原则)。

总结对比表:

特性接口 (Interface)抽象类 (Abstract Class)
设计理念自顶向下:先定义行为规范/契约自底向上:先有具体类,再抽取共性作为模板
定义关键字interfaceabstract class
方法实现Java 8 前无实现;Java 8+ 可有 default/static 方法可有抽象方法 (无实现) 和具体方法 (有实现)
构造器不能可以
成员变量默认 public static final (常量)可有各种修饰符 (private, protected, public),可变变量
多重继承一个类可实现多个接口一个类只能继承一个抽象类
强调行为、能力、契约、多态模板、父子关系、代码复用
实例化不能直接实例化不能直接实例化 (但有构造器供子类调用)
JDK 动态代理和 CGLIB 动态代理有什么区别?
  • JDK 动态代理:
    • 实现方式:基于 Java 的反射机制,在运行时为接口生成代理类。
    • 要求:目标对象必须实现一个或多个接口。代理类会实现这些相同的接口。
    • 优点:Java 原生支持,无需引入第三方库。
    • 缺点:只能代理接口,无法代理没有实现接口的普通类或最终类(final class)。
  • CGLIB 动态代理:
    • 实现方式:基于 ASM 字节码生成库,在运行时动态生成目标类的子类。
    • 要求:目标对象不能是 final 类或 final 方法(因为无法被继承和覆盖)。
    • 优点:可以代理没有实现接口的普通类,以及最终类中非 final 方法。
    • 缺点:需要引入第三方库(CGLIB 或 Spring AOP 内部集成的 ASM)。对于 final 类和 final 方法无能为力。

总结:JDK 代理是面向接口的代理,CGLIB 代理是面向类的代理(通过继承)。

你使用过 Java 的反射机制吗?如何应用反射?

反射机制允许程序在运行时检查或修改类的结构、方法和属性,而无需在编译时就知道这些信息。

具体应用举例:

  1. 框架开发:Spring 通过反射创建 Bean 实例,并调用其 setter 方法注入依赖。
  2. 动态代理:JDK 动态代理就是基于反射实现。
  3. 单元测试框架:JUnit 等框架通过反射来查找和执行测试方法。
  4. 序列化和反序列化:JSON 库(如 Gson、Jackson)通过反射来将 Java 对象转换为 JSON 字符串,或将 JSON 字符串解析为 Java 对象。
  5. 插件化开发:允许程序动态加载并执行外部定义的类。
  6. 配置文件解析:读取配置文件后,通过反射来调用相应的方法或设置属性。

如何应用反射:

要应用反射,通常涉及以下核心 API:

  • 获取 Class 对象Class.forName("com.example.MyClass")myObject.getClass()MyClass.class
  • 获取构造器并创建实例Class.getConstructor() / getConstructors(),然后 Constructor.newInstance()
  • 获取方法并调用Class.getMethod("methodName", paramTypes...) / getMethods(),然后 Method.invoke(object, args...)
  • 获取字段并操作Class.getField("fieldName") / getFields(),然后 Field.get(object) / Field.set(object, value)
说说 Java 中 HashMap 的原理?

想象一下你有一个巨大的衣柜,里面有很多抽屉,每个抽屉上都贴着一个标签(这个标签就是,Key),你把衣服(这个衣服就是,Value)放进对应的抽屉里。

HashMap 的原理也差不多:

  1. 标签变数字(哈希函数):当你给 HashMap 一个 Key(比如你要存一件红色的 T 恤,Key 就是“红色 T 恤”),它会先对这个 Key 进行一番计算,把这个 Key 变成一个数字(这个计算过程就叫做哈希函数,结果叫哈希值)。

    • 例子:“红色 T 恤”计算后可能变成数字 5
  2. 找抽屉位置(索引):这个数字可能很大,但你的衣柜抽屉是有限的。所以 HashMap 会用这个数字再做一次运算(通常是取模运算),把它映射到衣柜里某个具体的抽屉位置上。

    • 例子:数字 5 在你的 HashMap 里可能对应第 5 号抽屉。
  3. 放衣服进去(存储):

    • 空抽屉:如果这个抽屉是空的,那太好了,直接把你的衣服放进去(Key-Value 对)。
    • 已有衣服(碰撞):如果这个抽屉里已经有衣服了(比如“蓝色衬衫”也计算到了第 5 号抽屉,这就叫哈希碰撞),HashMap 不会直接覆盖掉。它会把新的衣服挂到这个抽屉里已经有的衣服的后面,形成一个链子。这个链子可以是链表(早期版本)或者红黑树(当链子太长时,为了提高查找效率,JDK8 及以后会转换)。
      • 例子:第 5 号抽屉里本来有“蓝色衬衫”,现在“红色 T 恤”也来了,它们就会排队,比如“蓝色衬衫” → “红色 T 恤”。
  4. 找衣服(查找):当你想要找“红色 T 恤”的时候,HashMap 也会对“红色 T 恤”这个 Key 进行同样的计算,得到同样的抽屉位置(第 5 号)。然后它会到第 5 号抽屉里,沿着链子一条一条地比对,直到找到“红色 T 恤”为止。

核心思想就是:

  • 快速定位:通过 Key 算出哈希值,再映射到数组索引,大大提高了查找效率,不用一个一个地遍历所有元素。
  • 解决冲突:用链表(或红黑树)来处理不同 Key 算出相同索引的情况。
Java 中有哪些集合类?请简单介绍

想象一下你有很多东西要管理,比如一堆照片、一份购物清单、一套学生花名册。Java 为了帮你管理这些东西,提供了各种“容器”,这些容器就是集合类。它们主要分成几大家族:

  1. List (列表) 家族:

    • 特点:有序(你放进去的顺序就是它存储的顺序),可重复(可以放两个一模一样的元素)。
    • 类比:你的购物清单。买了重复的东西没关系,物品的顺序也很重要。
    • 常见成员:
      • ArrayList:基于数组实现。查改快,增删慢(特别是中间位置)。就像一排整齐的格子,找东西方便,但中间加东西要挪动后面的所有格子。
      • LinkedList:基于链表实现。增删快,查改慢。就像一串手拉手的人,加个人很容易插队,但找人要一个一个问过去。
  2. Set (集合) 家族:

    • 特点:无序(你放进去的顺序不一定是你取出来的顺序),不可重复(只能放一个,重复的会被忽略)。
    • 类比:你的收藏品清单。收藏品不会重复,你也不关心它们摆放的先后顺序。
    • 常见成员:
      • HashSet:基于 HashMap 实现。查找、添加、删除都很快。它利用了哈希表的快速查找特性。
      • LinkedHashSet:继承 HashSet,内部用链表维护插入顺序。既保证不重复,又能记住你添加的顺序
      • TreeSet:基于红黑树实现。能自动排序(自然顺序或自定义顺序)。
  3. Map (映射) 家族:

    • 特点:存储 键值对 (Key-Value Pair)Key 唯一(就像抽屉标签不能重复),Value 可以重复。
    • 类比:学校的花名册。每个学生学号(Key)唯一对应一个学生姓名(Value),通过学号快速找到学生,但不同的学号可能对应相同的姓名(重名)。
    • 常见成员:
      • HashMap:基于哈希表实现。查找、添加、删除都很快(我们上面原理讲的就是它)。无序
      • LinkedHashMap:继承 HashMap,内部用链表维护插入顺序。既能快速查找,又能记住你添加的顺序
      • TreeMap:基于红黑树实现。能自动按 Key 排序

总结:

  • List:存一堆东西,讲究顺序,允许重复。
  • Set:存一堆不重复的东西,不讲究顺序。
  • Map:存一对一对的东西(Key-Value),Key 不能重复。
Java 中 HashMap 的扩容机制是怎样的?

想象你的衣柜一开始只有 16 个抽屉。你不断往里放衣服,直到抽屉越来越满,或者因为哈希碰撞导致链子越来越长,找衣服越来越慢。

HashMap 也一样,当它达到一定程度时,为了保持效率,它会进行扩容

  1. 扩容的触发条件:

    • 装载因子(Load Factor)HashMap 有一个“装载因子”,默认是 0.75。意思是当抽屉里衣服的数量(也就是 HashMap 里的元素个数)达到抽屉总数的 75% 时,就触发扩容。
      • 例子:如果当前有 16 个抽屉,当放了 16 * 0.75 = 12 件衣服时,就会开始考虑扩容。
    • 链表过长转为红黑树(JDK8+ 额外优化):在 JDK8 及以后,如果某个抽屉(即哈希桶)中的链表长度超过一个阈值(默认是 8),并且 HashMap 的总抽屉数量(容量)也达到了另一个阈值(默认是 64),那么这个链表会转换为红黑树,以提高查找效率。如果容量还没到 64,那么会先触发一次扩容。
  2. 扩容操作的步骤:

    • 容量翻倍HashMap 扩容时,通常会将抽屉的总数(底层数组的长度)翻倍。这是为了利用位运算来提高计算新索引的效率,因为 HashMap 的容量总是 2 的幂次方。
      • 例子:从 16 个抽屉变成 32 个抽屉。
    • 重新分配元素:这不是简单地把旧抽屉里的衣服搬到新衣柜里。由于抽屉总数变了,元素映射到索引的计算方式也会发生变化。
      • JDK8 之前,需要对所有元素重新计算哈希值,然后对新容量取模,来确定它们在新数组中的位置。
      • JDK8 及之后,扩容机制做了优化,元素在新数组中的位置判断更加高效。对于每个旧桶中的元素,它在新数组中的位置要么保持不变,要么移动到旧索引 + 旧容量的位置
      • 例子:一件“红色 T 恤”原来在 16 个抽屉的衣柜里对应第 5 号抽屉。现在变成 32 个抽屉的衣柜,它可能仍然在 5 号抽屉,也可能去了 5 + 16 = 21 号抽屉,这取决于它的哈希值的某一位。
  3. 扩容的优缺点与优化建议:

    • 优点:保证了 HashMap 在元素增多的情况下依然能保持较高的查找、插入和删除效率(接近 O(1))。
    • 缺点:扩容是一个比较耗时的操作。因为它需要遍历所有旧桶中的元素,并将其重新分配到新数组中。这个操作的复杂度为 O(n),其中 nHashMap 中元素的数量。
    • 优化建议:如果能预估 HashMap 的大小,最好在创建时就指定一个合适的初始容量。这样可以减少不必要的扩容次数,从而提升性能。

简单来说HashMap 会观察自己有多满。当它觉得太挤了(元素数量达到容量的 75%),就会造一个更大的衣柜(通常是原来的两倍大)。然后,它会高效地把所有衣服都拿出来,重新计算它们在新衣柜里的位置(JDK 8 后更高效),再全部放进去。这样虽然会有一小段时间的开销,但确保了以后还能继续快速找衣服。

你了解 Java 线程池的原理吗?

想象你是一个餐馆老板,生意很好,经常有客人来点餐。如果你每次有客人来都临时找一个厨师来做菜,做完就让他走,这样太麻烦了,而且招人、培训人都需要时间,效率很低。

线程池就相当于你的这个餐馆的“专属厨师团队”:

  1. 开门营业(创建线程池):你一开始就雇佣了一定数量的厨师(核心线程)。即使没有客人,他们也会待命。
  2. 客人点餐(提交任务):客人来了,点了一道菜(一个任务)。
    • 有空闲厨师:如果有空闲的厨师,他们就立刻接单做菜。
    • 没有空闲厨师,但有等候区:如果所有核心厨师都在忙,但餐馆里有等待区(任务队列),客人就在等待区排队,等着有厨师忙完。
    • 等候区也满了,需要临时加人:如果等待区也满了,厨师团队会考虑临时再雇佣一些临时厨师非核心线程/最大线程数)。
    • 人满为患(拒绝策略):如果临时厨师也雇满了,等待区也满了,餐馆实在忙不过来,就只能对新来的客人说“抱歉,我们现在无法接单”(拒绝策略)。
  3. 厨师工作(执行任务):厨师们拿到任务后就开始处理,处理完一个任务后,他们不会立即走人,而是会回到“待命状态”,等待下一个任务。
  4. 临时厨师下班(线程回收):如果临时厨师长时间没事做,餐馆就会让他们下班回家(线程回收),只留下核心厨师继续待命。

线程池的优点:

  • 降低资源消耗:避免了频繁创建和销毁线程的开销。
  • 提高响应速度:任务来了可以直接使用已有的线程,不用等待线程创建。
  • 提高线程的可管理性:可以统一管理、分配、监控和调整线程。
  • 避免线程过多:防止大量线程同时运行导致系统资源耗尽。

核心组件:

  • ThreadPoolExecutor:Java 中实现线程池的类。
  • 核心线程数 (corePoolSize):即使没有任务也会保留的最小线程数。
  • 最大线程数 (maximumPoolSize):允许创建的最大线程数。
  • 任务队列 (BlockingQueue):存放待执行任务的队列。
  • 空闲线程存活时间 (keepAliveTime):当线程数大于核心线程数时,多余的空闲线程在终止前等待新任务的最长时间。
  • 拒绝策略 (RejectedExecutionHandler):当线程池和任务队列都满时,如何处理新提交的任务。
你使用过哪些 Java 并发工具类?

在多线程编程中,为了让多个线程和谐、高效地工作,Java 提供了很多“工具”,可以帮助我们管理和协调线程。我主要使用过以下这些:

  1. ConcurrentHashMap (并发哈希表):

    • 作用:是一个线程安全且高效的哈希表,支持并发访问。解决了传统 HashMap 在多线程环境下不安全的问题,同时性能比 Hashtable 等加锁的 Map 更好。

    • 用法:多个线程可以同时对它进行读写操作,而不会导致线程安全问题。

    • 类比:就像一个有多个收银台的超市,不同收银员(线程)可以同时处理顾客(操作数据),互不干扰,大大提高了效率。

      java
      ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
      
      map.put("key1", 1); // 存入 key1-1
      System.out.println("存入 key1 -> " + map.get("key1")); // 读取 key1
      
      // 如果 key2 不存在,则存入 k -> 2 的结果 (即 2)
      map.computeIfAbsent("key2", k -> {
          System.out.println("'key2' 不存在,计算并存入 2");
          return 2;
      });
      System.out.println("第一次 computeIfAbsent 后 key2 的值:" + map.get("key2"));
      
      // 再次使用 computeIfAbsent。
      // 因为 key2 已经存在,k -> 3 的逻辑不会执行,map 中的值也不会改变
      map.computeIfAbsent("key2", k -> {
          System.out.println("'key2' 已经存在,k -> 3 的逻辑不会执行");
          return 3; // 此值不会被存入
      });
      System.out.println("第二次 computeIfAbsent 后 key2 的值:" + map.get("key2"));
  2. AtomicInteger (原子整数):

    • 作用:提供一种线程安全的方式对 int 类型进行原子操作,如增减、比较并设置。避免了使用 synchronized 关键字带来的性能开销。

    • 用法:适用于需要频繁对数值进行无锁操作的场景。

    • 类比:就像一个显示实时人数的计数器,多个人同时点击“进入”或“离开”,计数器也能准确、安全地更新,不需要排队等待。

      java
      AtomicInteger atomicInt = new AtomicInteger(0);
      atomicInt.incrementAndGet(); // 线程安全地递增
      atomicInt.decrementAndGet(); // 线程安全地递减
      atomicInt.compareAndSet(1, 2); // 比较并设置 (CAS 操作)
  3. Semaphore (信号量):

    • 作用:控制访问资源的线程数量,可以用来实现限流或访问控制

    • 用法:在资源有限的情况下,控制同时访问的线程数量。

    • 类比:就像一个有固定车位的停车场。每次有车(线程)想进入,就得先拿一个停车许可。停车位满了,后面的车就得等着。

      java
      Semaphore semaphore = new Semaphore(3); // 最多允许 3 个线程同时访问
      try {
          semaphore.acquire(); // 获取许可,如果许可不够则阻塞
          // 执行任务(例如访问数据库连接、文件等有限资源)
      } finally {
          semaphore.release(); // 释放许可
      }
  4. CyclicBarrier (循环屏障):

    • 作用:让一组线程到达一个共同的同步点,然后一起继续执行。常用于分阶段任务执行

    • 用法:适用于需要所有线程在某个点都完成再继续的场景。

    • 类比:就像一次团队协作的闯关游戏。设定 3 人一组,只有当所有 3 名队员都到达当前关卡的集合点时,大家才能一起通过,进入下一关。

      java
      CyclicBarrier barrier = new CyclicBarrier(3, () -> {
          System.out.println("所有线程都到达了屏障点,开始下一阶段任务");
      });
      Runnable task = () -> {
          try {
              // 执行任务的第一阶段
              System.out.println(Thread.currentThread().getName() + "完成第一阶段任务");
              barrier.await(); // 等待其他线程到达屏障
              // 执行任务的第二阶段
              System.out.println(Thread.currentThread().getName() + "完成第二阶段任务");
          } catch (Exception e) {
              e.printStackTrace();
          }
      };
      new Thread(task, "线程 A").start();
      new Thread(task, "线程 B").start();
      new Thread(task, "线程 C").start();
  5. CountDownLatch (倒计时门闩):

    • 作用:一个线程(或多个)等待其他线程完成操作。

    • 用法:适用于主线程需要等待多个子线程完成任务的场景。

    • 类比:就像一个项目的验收。项目经理(主线程)需要等待 3 个小组(子线程)各自完成他们的模块开发(countDown()),当所有模块都完成时,项目经理才能宣布项目完成(await())。

      java
      CountDownLatch latch = new CountDownLatch(3); // 设置计数器为 3
      Runnable task = () -> {
          try {
              // 执行任务
              System.out.println(Thread.currentThread().getName() + "完成任务");
          } finally {
              latch.countDown(); // 任务完成,计数器减一
          }
      };
      new Thread(task, "子线程 1").start();
      new Thread(task, "子线程 2").start();
      new Thread(task, "子线程 3").start();
      
      latch.await(); // 主线程等待所有任务完成,计数器归零时才继续
      System.out.println("所有任务都完成了,主线程继续执行");
  6. BlockingQueue (阻塞队列):

    • 作用:是一个线程安全的队列,支持阻塞操作。适用于生产者 - 消费者模式

    • 用法:生产者线程将元素放入队列,队列为满时生产者线程阻塞;消费者线程从队列中取出元素,队列为空时消费者线程阻塞。

    • 类比:就像一个有限容量的生产线传送带。生产工人(生产者线程)往上传送产品,如果传送带满了,他就得等等;质检工人(消费者线程)从传送带上取产品,如果传送带空了,他也得等等。

      java
      BlockingQueue<String> queue = new LinkedBlockingQueue<>(2); // 容量为 2 的阻塞队列
      // 生产者线程
      Runnable producer = () -> {
          try {
              queue.put("item1"); // 放入元素,如果队列满则阻塞
              System.out.println("生产者放入 item1");
              queue.put("item2");
              System.out.println("生产者放入 item2");
              queue.put("item3"); // 队列已满,生产者将在此阻塞,直到有消费者取出元素
              System.out.println("生产者放入 item3");
          } catch (InterruptedException e) {
              e.printStackTrace();
          }
      };
      // 消费者线程
      Runnable consumer = () -> {
          try {
              Thread.sleep(1000); // 模拟消费者处理需要时间
              String item = queue.take(); // 取出元素,如果队列空则阻塞
              System.out.println("消费者取出 " + item);
              item = queue.take();
              System.out.println("消费者取出 " + item);
              item = queue.take();
              System.out.println("消费者取出 " + item);
          } catch (InterruptedException e) {
              e.printStackTrace();
          }
      };
      new Thread(producer, "生产者").start();
      new Thread(consumer, "消费者").start();
什么是 Java 的 CAS(Compare-And-Swap)操作?

CAS 是一种非常巧妙且高效的并发操作,它避免了传统锁机制(比如 synchronized)带来的性能开销。

CAS 的核心思想:

  • 它包含三个操作数:

    1. 内存位置 (V):你要操作的变量在内存中的位置。
    2. 预期旧值 (A):你认为这个变量现在应该是什么值。
    3. 新值 (B):如果变量的值符合你的预期,你想把它改成什么值。

操作过程:

  1. 线程 A 说:“我打算把内存中 X 位置的值从 旧值1 改成 新值2。”
  2. CAS 操作:它会去检查 X 位置的值是不是真的还是 旧值1
    • 如果是 旧值1:说明在线程 A 检查到它并打算修改的这段时间里,没有其他线程动过它,那么 CAS 操作就成功,把 X 位置的值改成 新值2
    • 如果不是 旧值1:说明有其他线程在我不知情的情况下,已经把 X 位置的值改掉了。CAS 操作就失败,不进行任何修改。
  3. 线程 A 的后续:如果 CAS 失败,线程 A 通常会选择重试(重新读取当前值,再次尝试 CAS),或者放弃

类比:

  • 想象你正在买一件限量版 T 恤,只剩最后一件了。

    • 内存位置 (V):就是“这件 T 恤是否还有货”的状态,比如用数字 1 表示有货,0 表示没货。
    • 预期旧值 (A):你去买的时候,你心想这件 T 恤“应该还有货”(预期旧值是 1)。
    • 新值 (B):如果有货,你就把它买走,状态变成“没货”(新值是 0)。
  • 你拿着钱去柜台:

    1. 你(线程 A)对店员(CAS 操作)说:“如果这件 T 恤还有货(当前是 1),我就把它买走,变成没货(改成 0)。”
    2. 店员(CAS)立刻看一眼货架:
      • 如果真的还有货(是 1:那么你买成功了,T 恤变成没货(改成 0),交易完成。
      • 如果已经没货了(是 0:说明在你来之前,别人已经买走了。你的交易失败。
    3. 你买失败后,通常会选择再看一眼其他款式的 T 恤(重试其他操作),或者干脆不买了(放弃)

CAS 的优点:

  • 非阻塞:不会像锁一样导致其他线程阻塞等待,而是失败后重试,减少了上下文切换的开销。
  • 原子性:比较和交换是硬件层面提供的原子操作,要么都成功,要么都失败,不会出现中间状态。
  • 适用于竞争不激烈的情况:在线程冲突较少的情况下,CAS 的性能通常优于锁。

CAS 的缺点:

  • ABA 问题:如果一个值从 A 变成 B,又从 B 变回 A,那么 CAS 会认为没有发生变化,但实际上已经变过了。这个问题可以通过引入版本号(AtomicStampedReference)来解决。
  • 循环时间长开销大:如果 CAS 操作总是失败,会不断自旋重试,浪费 CPU 资源。
  • 只能保证一个共享变量的原子操作:如果需要同时操作多个共享变量,CAS 就无能为力了,需要使用锁或者 AtomicReference

Java 中的 java.util.concurrent.atomic 包下的类(如 AtomicIntegerAtomicLong)就是基于 CAS 实现的,它们提供了一些基本类型的原子操作。

说说 AQS 吧?

简单来说,AQS(AbstractQueuedSynchronizer,抽象队列同步器)起到了一个抽象、封装的作用。

  1. 它提供了一套通用的机制:将线程排队、入队、加锁、中断、唤醒等通用的同步操作抽象出来,并封装在一个框架里。这使得 JUC 包中各种复杂的同步组件(如 ReentrantLockCountDownLatchSemaphore 等)能够基于这套机制来实现,避免了重复造轮子,大大简化了并发工具的开发。

  2. 核心构成:它主要通过维护两个关键部分来管理线程对共享资源的访问:

    • 一个共享状态(state 变量):这个 state 变量用 volatile 修饰,表示当前资源的同步状态。
      • 例如,在独占锁(如 ReentrantLock 的独占模式)中,state0 表示资源未被占用,为 1 表示资源已被占用。
      • 在共享锁(如 Semaphore)中,state 可能表示剩余的许可数量。
    • 一个先进先出(FIFO)的等待队列(CLH 队列的变体):当线程尝试获取资源失败时(例如,锁已经被其他线程持有),它不会立即放弃,而是会被加入到 AQS 的等待队列中。
      • 这个队列是一个双向链表结构,它的节点(Node)包含了对等待线程的引用、线程的等待状态(例如,是否需要唤醒、是否被取消)以及指向前驱和后继节点的指针。
      • 队列的头部通常是当前正在等待获取锁的线程或即将被唤醒的线程。
  3. 如何工作:

    • 获取资源(acquire):当线程尝试获取资源时,AQS 会首先检查 state。如果 state 允许获取(比如锁未被占用),线程就成功获取资源。如果 state 不允许获取,线程就会被封装成一个节点,加入到等待队列的尾部,并被阻塞挂起。
    • 释放资源(release):当持有资源的线程释放资源时,AQS 会修改 state 的值。如果修改后 state 允许其他线程获取资源,并且等待队列中有线程,AQS 就会唤醒队列头部的线程去尝试获取资源。
  4. 定制性:AQS 是一个抽象类,它定义了获取和释放资源的骨架方法(如 acquire()release()),但具体的“如何判断资源是否能获取”、“如何修改 state”等逻辑,需要具体的实现类(如 ReentrantLock 的内部类 Sync)来自己控制和实现。比如,独占锁和共享锁对 state 的操作逻辑就不同。

  5. AQS 常见的实现类有ReentrantLockCountDownLatchSemaphore 等等。

Synchronized 和 ReentrantLock 有什么区别?
  • Synchronized 是 Java 内置的关键字,实现基本的同步机制。
    • 特点:不支持超时、通常是非公平的、不可中断、不支持多个条件变量。
  • ReentrantLock 是 JUC 库提供的,由 JDK 1.5 引入。
    • 特点:支持设置超时时间、可以避免死锁、比较灵活,并且支持公平锁、可中断、支持多个条件判断。

共同点:

它们都是可重入锁,这意味着持有锁的线程可以再次获取该锁而不会发生死锁。

核心区别:

  • ReentrantLock 需要手动加锁和解锁(通过 lock()unlock() 方法),这就要求开发者必须在 finally 块中确保 unlock() 被调用,以避免死锁。
  • Synchronized 不需要手动解锁,它是自动加锁和解锁的(进入同步块/方法时加锁,退出时自动解锁,无论是正常退出还是异常退出)。
Java 中 volatile 关键字的作用是什么?

volatile 关键字的主要作用是保证变量的可见性禁止指令重排优化

  1. 可见性 (Visibility):

    • 问题背景:在多线程编程中,为了提高性能,每个线程通常会有自己的工作内存(或称为本地缓存),用于存储变量的副本。当一个线程修改了一个变量的值时,这个修改可能只会先反映在它的工作内存中,而不会立即同步到主内存。这就导致了另一个线程在读取同一个变量时,可能会从自己的旧的本地缓存中读取到过期的值,而不是主内存中的最新值。这种现象被称为“缓存一致性问题”,或者更通俗地说,“看见旧值”的现象。

    • volatile 的作用:

      • volatile 关键字确保变量的可见性。当一个线程修改了 volatile 变量的值,这个新值会立即被刷新到主内存中
      • 同时,当其他线程在读取该 volatile 变量时,它们会强制性地从主内存中重新获取最新值,而不是使用自己本地缓存中的旧值。
      • 这样可以避免线程间由于缓存不一致问题导致的“看见旧值”的现象,保证了多个线程对 volatile 变量的读写操作总是能看到最新状态。
  2. 禁止指令重排序 (Ordering):

    • 问题背景:为了提高程序执行效率,编译器和处理器可能会对指令进行优化,调整其执行顺序,这种优化被称为“指令重排序”。在单线程环境下,这种重排序不会改变程序的最终结果(遵循“as-if-serial”语义)。然而,在多线程环境下,如果没有正确的同步机制,指令重排序可能会导致意想不到的错误结果。

    • volatile 的作用:

      • volatile 变量还通过插入内存屏障(Memory Barrier)来禁止特定情况下的指令重排序,从而保证程序的执行顺序符合预期。
      • 具体来说:
        • volatile 变量的写操作会在其前面插入一个 StoreStore 屏障,在其后面插入一个 StoreLoad 屏障。这确保了在 volatile 写操作之前的代码执行结果,对所有线程都是可见的,并且 volatile 写操作本身必须在后续的任何读操作之前完成。
        • volatile 变量的读操作会在其后面插入一个 LoadLoad 屏障和 LoadStore 屏障。这确保了 volatile 读操作能够读取到最新的值,并且在 volatile 读操作之后的代码,不会被重排序到 volatile 读操作之前。
      • 这种内存屏障机制确保了在多线程环境下,某些代码块执行顺序的可预测性,避免了因指令重排序引发的并发问题。
  3. 重要补充:volatile 不保证原子性

    虽然 volatile 解决了可见性和部分有序性问题,但它不保证操作的原子性

    • 原子性是指一个操作是不可中断的,要么完全执行,要么不执行。
    • 例如,volatile int count = 0; count++; 这个操作看起来很简单,但它实际包含了三个步骤:
      1. 读取 count 的当前值。
      2. count 进行加 1 操作。
      3. 将新值写入 count
    • 如果多个线程同时执行 count++volatile 只能保证每次读写 count 时,都是从主内存获取最新值并及时刷新到主内存。但是,它不能保证从读取到写入这三个步骤作为一个整体是不可分割的。在一个线程读取 count 后,另一个线程可能在它写入之前也读取了 count 的旧值,导致最终的 count 值不正确。
    • 对于需要原子性保证的操作(如计数器增减),应该使用 java.util.concurrent.atomic 包下的原子类(如 AtomicInteger)或使用 synchronizedReentrantLock

总结:

volatile 适用于:当一个变量被多个线程共享,但其读写操作是独立的,不依赖于变量的当前值,或者读操作远多于写操作,并且写操作不依赖于读操作的结果(即只需要保证可见性和有序性,不需要原子性)的场景。例如,一个用于控制线程停止的 boolean 标记。

贡献者

The avatar of contributor named as LI SIR LI SIR

页面历史