\":()V\n 13: aload_1\n 14: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;\n 17: aload_2\n 18: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;\n 21: invokevirtual #7 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;\n 24: astore_3\n 25: return\n```\n\n\n也就是说,上面的代码相当于:\n\n\n```java\nString a = \"hello \";\nString b = \"world!\";\nStringBuilder sb = new StringBuilder();\nsb.append(a);\nsb.append(b);\nString ab = sb.toString();\n```\n\n\n因此,如果笼统地讲,通过加号拼接字符串时会创建多个 String 对象是不准确的。因为加号拼接在编译期还会创建一个 StringBuilder 对象,最终调用 `toString()` 方法的时候再返回一个新的 String 对象。\n\n\n```java\n@Override\npublic String toString() {\n // Create a copy, don't share the array\n return new String(value, 0, count);\n}\n```\n\n\n那除了使用 `+` 号来拼接字符串,还有 `StringBuilder.append()`、`String.join()` 等方式。\n\n#### [如何保证 String 不可变?](#如何保证-string-不可变)\n\n第一,String 类内部使用一个私有的字符数组来存储字符串数据。这个字符数组在创建字符串时被初始化,之后不允许被改变。\n\n\n```java\nprivate final char value[];\n```\n\n\n第二,String 类没有提供任何可以修改其内容的公共方法,像 concat 这些看似修改字符串的操作,实际上都是返回一个新创建的字符串对象,而原始字符串对象保持不变。\n\n\n```java\npublic String concat(String str) {\n if (str.isEmpty()) {\n return this;\n }\n int len = value.length;\n int otherLen = str.length();\n char buf[] = Arrays.copyOf(value, len + otherLen);\n str.getChars(buf, len);\n return new String(buf, true);\n}\n```\n\n\n第三,String 类本身被声明为 final,这意味着它不能被继承。这防止了子类可能通过添加修改方法来改变字符串内容的可能性。\n\n\n```java\npublic final class String\n```"
+ },
+ {
+ "id": 37,
+ "question": "intern 方法有什么作用?",
+ "answer": "JDK 源码里已经对这个方法进行了说明:\n\n\n```java\n* \n* When the intern method is invoked, if the pool already contains a\n* string equal to this {@code String} object as determined by\n* the {@link #equals(Object)} method, then the string from the pool is\n* returned. Otherwise, this {@code String} object is added to the\n* pool and a reference to this {@code String} object is returned.\n*
\n```\n\n\n意思也很好懂:\n\n* 如果当前字符串内容存在于字符串常量池(即 equals()方法为 true,也就是内容一样),直接返回字符串常量池中的字符串\n* 否则,将此 String 对象添加到池中,并返回 String 对象的引用"
+ }
+ ]
+ },
+ {
+ "id": 5,
+ "categoryName": "Integer",
+ "questions": [
+ {
+ "id": 38,
+ "question": "Integer a= 127,Integer b = 127;Integer c= 128,Integer d = 128;相等吗?",
+ "answer": "a 和 b 相等,c 和 d 不相等。\n\n这个问题涉及到 Java 的自动装箱机制以及`Integer`类的缓存机制。\n\n对于第一对:\n\n\n```java\nInteger a = 127;\nInteger b = 127;\n```\n\n\n`a`和`b`是相等的。这是因为 Java 在自动装箱过程中,会使用`Integer.valueOf()`方法来创建`Integer`对象。\n\n`Integer.valueOf()`方法会针对数值在-128 到 127 之间的`Integer`对象使用缓存。因此,`a`和`b`实际上引用了常量池中相同的`Integer`对象。\n\n对于第二对:\n\n\n```java\nInteger c = 128;\nInteger d = 128;\n```\n\n\n`c`和`d`不相等。这是因为 128 超出了`Integer`缓存的范围(-128 到 127)。\n\n因此,自动装箱过程会为`c`和`d`创建两个不同的`Integer`对象,它们有不同的引用地址。\n\n可以通过`==`运算符来检查它们是否相等:\n\n\n```java\nSystem.out.println(a == b); // 输出true\nSystem.out.println(c == d); // 输出false\n```\n\n\n要比较`Integer`对象的数值是否相等,应该使用`equals`方法,而不是`==`运算符:\n\n\n```java\nSystem.out.println(a.equals(b)); // 输出true\nSystem.out.println(c.equals(d)); // 输出true\n```\n\n\n使用`equals`方法时,`c`和`d`的比较结果为`true`,因为`equals`比较的是对象的数值,而不是引用地址。\n\n#### [什么是 Integer 缓存?](#什么是-integer-缓存)\n\n就拿 Integer 的缓存吃来说吧。根据实践发现,大部分的数据操作都集中在值比较小的范围,因此 Integer 搞了个缓存池,默认范围是 -128 到 127。\n\n\n\n当我们使用自动装箱来创建这个范围内的 Integer 对象时,Java 会直接从缓存中返回一个已存在的对象,而不是每次都创建一个新的对象。这意味着,对于这个值范围内的所有 Integer 对象,它们实际上是引用相同的对象实例。\n\nInteger 缓存的主要目的是优化性能和内存使用。对于小整数的频繁操作,使用缓存可以显著减少对象创建的数量。\n\n可以在运行的时候添加 `-Djava.lang.Integer.IntegerCache.high=1000` 来调整缓存池的最大值。\n\n\n\n引用是 Integer 类型,= 右侧是 int 基本类型时,会进行自动装箱,调用的其实是 `Integer.valueOf()`方法,它会调用 IntegerCache。\n\n\n```java\npublic static Integer valueOf(int i) {\n if (i >= IntegerCache.low && i <= IntegerCache.high)\n return IntegerCache.cache[i + (-IntegerCache.low)];\n return new Integer(i);\n}\n```\n\n\nIntegerCache 是一个静态内部类,在静态代码块中会初始化好缓存的值。\n\n\n```java\nprivate static class IntegerCache {\n ……\n static {\n //创建Integer对象存储\n for(int k = 0; k < cache.length; k++)\n cache[k] = new Integer(j++);\n ……\n }\n}\n```\n\n\n#### [new Integer(10) == new Integer(10) 相等吗](#new-integer-10-new-integer-10-相等吗)\n\n在 Java 中,使用`new Integer(10) == new Integer(10)`进行比较时,结果是 false。\n\n这是因为 new 关键字会在堆(Heap)上为每个 Integer 对象分配新的内存空间,所以这里创建了两个不同的 Integer 对象,它们有不同的内存地址。\n\n当使用==运算符比较这两个对象时,实际上比较的是它们的内存地址,而不是它们的值,因此即使两个对象代表相同的数值(10),结果也是 false。"
+ },
+ {
+ "id": 39,
+ "question": "String 怎么转成 Integer 的?原理?",
+ "answer": "PS:这道题印象中在一些面经中出场过几次。\n\nString 转成 Integer,主要有两个方法:\n\n* Integer.parseInt(String s)\n* Integer.valueOf(String s)\n\n不管哪一种,最终还是会调用 Integer 类内中的`parseInt(String s, int radix)`方法。\n\n抛去一些边界之类的看看核心代码:\n\n\n```java\npublic static int parseInt(String s, int radix)\n throws NumberFormatException\n {\n\n int result = 0;\n //是否是负数\n boolean negative = false;\n //char字符数组下标和长度\n int i = 0, len = s.length();\n ……\n int digit;\n //判断字符长度是否大于0,否则抛出异常\n if (len > 0) {\n ……\n while (i < len) {\n // Accumulating negatively avoids surprises near MAX_VALUE\n //返回指定基数中字符表示的数值。(此处是十进制数值)\n digit = Character.digit(s.charAt(i++),radix);\n //进制位乘以数值\n result *= radix;\n result -= digit;\n }\n }\n //根据上面得到的是否负数,返回相应的值\n return negative ? result : -result;\n }\n```\n\n\n去掉枝枝蔓蔓(当然这些枝枝蔓蔓可以去看看,源码 cover 了很多情况),其实剩下的就是一个简单的字符串遍历计算,不过计算方式有点反常规,是用负的值累减。\n\n"
+ }
+ ]
+ },
+ {
+ "id": 6,
+ "categoryName": "Object",
+ "questions": [
+ {
+ "id": 40,
+ "question": "Object 类的常见方法?",
+ "answer": "在 Java 中,经常提到一个词“万物皆对象”,其中的“万物”指的是 Java 中的所有类,而这些类都是 Object 类的子类。\n\nObject 主要提供了 11 个方法,大致可以分为六类:\n\n\n\n#### [对象比较:](#对象比较)\n\n①、`public native int hashCode()` :,用于返回对象的哈希码。\n\n\n```java\npublic native int hashCode();\n```\n\n\n按照约定,相等的对象必须具有相等的哈希码。如果重写了 equals 方法,就应该重写 hashCode 方法。可以使用 方法来生成哈希码。\n\n\n```java\npublic int hashCode() {\n return Objects.hash(name, age);\n}\n```\n\n\n②、`public boolean equals(Object obj)`:用于比较 2 个对象的内存地址是否相等。\n\n\n```java\npublic boolean equals(Object obj) {\n return (this == obj);\n}\n```\n\n\n如果比较的是两个对象的值是否相等,就要重写该方法,比如 、Integer 类等都重写了该方法。举个例子,假如有一个 Person 类,我们认为只要年龄和名字相同,就是同一个人,那么就可以这样重写 equals 方法:\n\n\n```java\nclass Person1 {\n private String name;\n private int age;\n\n // 省略 gettter 和 setter 方法\n\n public boolean equals(Object obj) {\n if (this == obj) {\n return true;\n }\n if (obj instanceof Person1) {\n Person1 p = (Person1) obj;\n return this.name.equals(p.getName()) && this.age == p.getAge();\n }\n return false;\n }\n}\n```\n\n\n#### [对象拷贝:](#对象拷贝)\n\n`protected native Object clone() throws CloneNotSupportedException`:naitive 方法,返回此对象的一个副本。默认实现只做,且类必须实现 Cloneable 接口。\n\nObject 本身没有实现 Cloneable 接口,所以在不重写 clone 方法的情况下直接直接调用该方法会发生 CloneNotSupportedException 异常。\n\n#### [对象转字符串:](#对象转字符串)\n\n`public String toString()`:返回对象的字符串表示。默认实现返回类名@哈希码的十六进制表示,但通常会被重写以返回更有意义的信息。\n\n\n```java\npublic String toString() {\n return getClass().getName() + \"@\" + Integer.toHexString(hashCode());\n}\n```\n\n\n比如说一个 Person 类,我们可以重写 toString 方法,返回一个有意义的字符串:\n\n\n```java\npublic String toString() {\n return \"Person{\" +\n \"name='\" + name + '\\'' +\n \", age=\" + age +\n '}';\n}\n```\n\n\n当然了,这项工作也可以直接交给 IDE,比如 IntelliJ IDEA,直接右键选择 Generate,然后选择 toString 方法,就会自动生成一个 toString 方法。\n\n也可以交给 ,使用 @Data 注解,它会自动生成 toString 方法。\n\n数组也是一个对象,所以通常我们打印数组的时候,会看到诸如 `[I@1b6d3586` 这样的字符串,这个就是 int 数组的哈希码。\n\n#### [多线程调度:](#多线程调度)\n\n每个对象都可以调用 Object 的 wait/notify 方法来实现等待/通知机制。我们来写一个例子:\n\n\n```java\npublic class WaitNotifyDemo {\n public static void main(String[] args) {\n Object lock = new Object();\n new Thread(() -> {\n synchronized (lock) {\n System.out.println(\"线程1:我要等待\");\n try {\n lock.wait();\n } catch (InterruptedException e) {\n e.printStackTrace();\n }\n System.out.println(\"线程1:我被唤醒了\");\n }\n }).start();\n new Thread(() -> {\n synchronized (lock) {\n System.out.println(\"线程2:我要唤醒\");\n lock.notify();\n System.out.println(\"线程2:我已经唤醒了\");\n }\n }).start();\n }\n}\n```\n\n\n解释一下:\n\n* 线程 1 先执行,它调用了 `lock.wait()` 方法,然后进入了等待状态。\n* 线程 2 后执行,它调用了 `lock.notify()` 方法,然后线程 1 被唤醒了。\n\n①、`public final void wait() throws InterruptedException`:调用该方法会导致当前线程等待,直到另一个线程调用此对象的`notify()`方法或`notifyAll()`方法。\n\n②、`public final native void notify()`:唤醒在此对象监视器上等待的单个线程。如果有多个线程等待,选择一个线程被唤醒。\n\n③、`public final native void notifyAll()`:唤醒在此对象监视器上等待的所有线程。\n\n④、`public final native void wait(long timeout) throws InterruptedException`:等待 timeout 毫秒,如果在 timeout 毫秒内没有被唤醒,会自动唤醒。\n\n⑥、`public final void wait(long timeout, int nanos) throws InterruptedException`:更加精确了,等待 timeout 毫秒和 nanos 纳秒,如果在 timeout 毫秒和 nanos 纳秒内没有被唤醒,会自动唤醒。\n\n#### [反射:](#反射)\n\n`public final native Class> getClass()`:用于获取对象的类信息,如类名。比如说:\n\n\n```java\npublic class GetClassDemo {\n public static void main(String[] args) {\n Person p = new Person();\n Class extends Person> aClass = p.getClass();\n System.out.println(aClass.getName());\n }\n}\n```\n\n\n输出结果:\n\n\n```text\ncom.itwanger.Person\n```\n\n\n#### [垃圾回收:](#垃圾回收)\n\n`protected void finalize() throws Throwable`:当垃圾回收器决定回收对象占用的内存时调用此方法。用于清理资源,但 Java 不推荐使用,因为它不可预测且容易导致问题,Java 9 开始已被弃用。\n\n"
+ }
+ ]
+ },
+ {
+ "id": 7,
+ "categoryName": "异常处理",
+ "questions": [
+ {
+ "id": 41,
+ "question": "Java 中异常处理体系?",
+ "answer": "Java 中的异常处理机制用于处理程序运行过程中可能发生的各种异常情况,通常通过 try-catch-finally 语句和 throw 关键字来实现。\n\n\n\n`Throwable` 是 Java 语言中所有错误和异常的基类。它有两个主要的子类:Error 和 Exception,这两个类分别代表了 Java 异常处理体系中的两个分支。\n\nError 类代表那些严重的错误,这类错误通常是程序无法处理的。比如,OutOfMemoryError 表示内存不足,StackOverflowError 表示栈溢出。这些错误通常与 JVM 的运行状态有关,一旦发生,应用程序通常无法恢复。\n\nException 类代表程序可以处理的异常。它分为两大类:编译时异常(Checked Exception)和运行时异常(Runtime Exception)。\n\n①、编译时异常(Checked Exception):这类异常在编译时必须被显式处理(捕获或声明抛出)。\n\n如果方法可能抛出某种编译时异常,但没有捕获它(try-catch)或没有在方法声明中用 throws 子句声明它,那么编译将不会通过。例如:IOException、SQLException 等。\n\n②、运行时异常(Runtime Exception):这类异常在运行时抛出,它们都是 RuntimeException 的子类。对于运行时异常,Java 编译器不要求必须处理它们(即不需要捕获也不需要声明抛出)。\n\n运行时异常通常是由程序逻辑错误导致的,如 NullPointerException、IndexOutOfBoundsException 等。"
+ },
+ {
+ "id": 42,
+ "question": "异常的处理方式?",
+ "answer": "\n\n①、遇到异常时可以不处理,直接通过throw 和 throws 抛出异常,交给上层调用者处理。\n\nthrows 关键字用于声明可能会抛出的异常,而 throw 关键字用于抛出异常。\n\n\n```java\npublic void test() throws Exception {\n throw new Exception(\"抛出异常\");\n}\n```\n\n\n②、使用 try-catch 捕获异常,处理异常。\n\n\n```java\ntry {\n //包含可能会出现异常的代码以及声明异常的方法\n}catch(Exception e) {\n //捕获异常并进行处理\n}finally {\n //可选,必执行的代码\n}\n```\n\n\n#### [catch和finally的异常可以同时抛出吗?](#catch和finally的异常可以同时抛出吗)\n\n如果 catch 块抛出一个异常,而 finally 块中也抛出异常,那么最终抛出的将是 finally 块中的异常。catch 块中的异常会被丢弃,而 finally 块中的异常会覆盖并向上传递。\n\n\n```java\npublic class Example {\n public static void main(String[] args) {\n try {\n throw new Exception(\"Exception in try\");\n } catch (Exception e) {\n throw new RuntimeException(\"Exception in catch\");\n } finally {\n throw new IllegalArgumentException(\"Exception in finally\");\n }\n }\n}\n```\n\n\n* try 块首先抛出一个 Exception。\n* 控制流进入 catch 块,catch 块中又抛出了一个 RuntimeException。\n* 但是在 finally 块中,抛出了一个 IllegalArgumentException,最终程序抛出的异常是 finally 块中的 IllegalArgumentException。\n\n虽然 catch 和 finally 中的异常不能同时抛出,但可以手动捕获 finally 块中的异常,并将 catch 块中的异常保留下来,避免被覆盖。常见的做法是使用一个变量临时存储 catch 中的异常,然后在 finally 中处理该异常:\n\n\n```java\npublic class Example {\n public static void main(String[] args) {\n Exception catchException = null;\n try {\n throw new Exception(\"Exception in try\");\n } catch (Exception e) {\n catchException = e;\n throw new RuntimeException(\"Exception in catch\");\n } finally {\n try {\n throw new IllegalArgumentException(\"Exception in finally\");\n } catch (IllegalArgumentException e) {\n if (catchException != null) {\n System.out.println(\"Catch exception: \" + catchException.getMessage());\n }\n System.out.println(\"Finally exception: \" + e.getMessage());\n }\n }\n }\n}\n```\n"
+ },
+ {
+ "id": 43,
+ "question": "三道经典异常处理代码题",
+ "answer": "#### [题目 1](#题目-1)\n\n\n```java\npublic class TryDemo {\n public static void main(String[] args) {\n System.out.println(test());\n }\n public static int test() {\n try {\n return 1;\n } catch (Exception e) {\n return 2;\n } finally {\n System.out.print(\"3\");\n }\n }\n}\n```\n\n\n在`test()`方法中,首先有一个`try`块,接着是一个`catch`块(用于捕获异常),最后是一个`finally`块(无论是否捕获到异常,`finally`块总会执行)。\n\n①、`try`块中包含一条`return 1;`语句。正常情况下,如果`try`块中的代码能够顺利执行,那么方法将返回数字`1`。在这个例子中,`try`块中没有任何可能抛出异常的操作,因此它会正常执行完毕,并准备返回`1`。\n\n②、由于`try`块中没有异常发生,所以`catch`块中的代码不会执行。\n\n③、无论前面的代码是否发生异常,`finally`块总是会执行。在这个例子中,`finally`块包含一条`System.out.print(\"3\");`语句,意味着在方法结束前,会在控制台打印出`3`。\n\n当执行`main`方法时,控制台的输出将会是:\n\n\n```text\n31\n```\n\n\n这是因为`finally`块确保了它包含的`System.out.print(\"3\");`会执行并打印`3`,随后`test()`方法返回`try`块中的值`1`,最终结果就是`31`。\n\n#### [题目 2](#题目-2)\n\n\n```java\npublic class TryDemo {\n public static void main(String[] args) {\n System.out.println(test1());\n }\n public static int test1() {\n try {\n return 2;\n } finally {\n return 3;\n }\n }\n}\n```\n\n\n执行结果:3。\n\ntry 返回前先执行 finally,结果 finally 里不按套路出牌,直接 return 了,自然也就走不到 try 里面的 return 了。\n\n注意:finally 里面使用 return 仅存在于面试题中,实际开发这么写要挨吊的(😂)。\n\n#### [题目 3](#题目-3)\n\n\n```java\npublic class TryDemo {\n public static void main(String[] args) {\n System.out.println(test1());\n }\n public static int test1() {\n int i = 0;\n try {\n i = 2;\n return i;\n } finally {\n i = 3;\n }\n }\n}\n```\n\n\n执行结果:2。\n\n大家可能会以为结果应该是 3,因为在 return 前会执行 finally,而 i 在 finally 中被修改为 3 了,那最终返回 i 不是应该为 3 吗?\n\n但其实,在执行 finally 之前,JVM 会先将 i 的结果暂存起来,然后 finally 执行完毕后,会返回之前暂存的结果,而不是返回 i,所以即使 i 已经被修改为 3,最终返回的还是之前暂存起来的结果 2。"
+ }
+ ]
+ },
+ {
+ "id": 8,
+ "categoryName": "I/O",
+ "questions": [
+ {
+ "id": 44,
+ "question": "Java 中 IO 流分为几种?",
+ "answer": "Java IO 流的划分可以根据多个维度进行,包括数据流的方向(输入或输出)、处理的数据单位(字节或字符)、流的功能以及流是否支持随机访问等。\n\n#### [按照数据流方向如何划分?](#按照数据流方向如何划分)\n\n* 输入流(Input Stream):从源(如文件、网络等)读取数据到程序。\n* 输出流(Output Stream):将数据从程序写出到目的地(如文件、网络、控制台等)。\n\n#### [按处理数据单位如何划分?](#按处理数据单位如何划分)\n\n* 字节流(Byte Streams):以字节为单位读写数据,主要用于处理二进制数据,如音频、图像文件等。\n* 字符流(Character Streams):以字符为单位读写数据,主要用于处理文本数据。\n\n\n\n#### [按功能如何划分?](#按功能如何划分)\n\n* 节点流(Node Streams):直接与数据源或目的地相连,如 FileInputStream、FileOutputStream。\n* 处理流(Processing Streams):对一个已存在的流进行包装,如缓冲流 BufferedInputStream、BufferedOutputStream。\n* 管道流(Piped Streams):用于线程之间的数据传输,如 PipedInputStream、PipedOutputStream。\n\n#### [IO 流用到了什么设计模式?](#io-流用到了什么设计模式)\n\n其实,Java 的 IO 流体系还用到了一个设计模式——**装饰器模式**。\n\n\n\n#### [Java 缓冲区溢出,如何预防](#java-缓冲区溢出-如何预防)\n\nJava 缓冲区溢出主要是由于向缓冲区写入的数据超过其能够存储的数据量。可以采用这些措施来避免:\n\n①、**合理设置缓冲区大小**:在创建缓冲区时,应根据实际需求合理设置缓冲区的大小,避免创建过大或过小的缓冲区。\n\n②、**控制写入数据量**:在向缓冲区写入数据时,应该控制写入的数据量,确保不会超过缓冲区的容量。Java 的 ByteBuffer 类提供了`remaining()`方法,可以获取缓冲区中剩余的可写入数据量。\n\n\n```java\nimport java.nio.ByteBuffer;\n\npublic class ByteBufferExample {\n\n public static void main(String[] args) {\n // 模拟接收到的数据\n byte[] receivedData = {1, 2, 3, 4, 5};\n int bufferSize = 1024; // 设置一个合理的缓冲区大小\n\n // 创建ByteBuffer\n ByteBuffer buffer = ByteBuffer.allocate(bufferSize);\n\n // 写入数据之前检查容量是否足够\n if (buffer.remaining() >= receivedData.length) {\n buffer.put(receivedData);\n } else {\n System.out.println(\"Not enough space in buffer to write data.\");\n }\n\n // 准备读取数据:将limit设置为当前位置,position设回0\n buffer.flip();\n\n // 读取数据\n while (buffer.hasRemaining()) {\n byte data = buffer.get();\n System.out.println(\"Read data: \" + data);\n }\n\n // 清空缓冲区以便再次使用\n buffer.clear();\n }\n}\n```"
+ },
+ {
+ "id": 45,
+ "question": "既然有了字节流,为什么还要有字符流?",
+ "answer": "其实字符流是由 Java 虚拟机将字节转换得到的,问题就出在这个过程还比较耗时,并且,如果我们不知道编码类型就很容易出现乱码问题。\n\n所以, I/O 流就干脆提供了一个直接操作字符的接口,方便我们平时对字符进行流操作。如果音频文件、图片等媒体文件用字节流比较好,如果涉及到字符的话使用字符流比较好。\n\n#### [文本存储是字节流还是字符流,视频文件呢?](#文本存储是字节流还是字符流-视频文件呢)\n\n在计算机中,文本和视频都是按照字节存储的,只是如果是文本文件的话,我们可以通过字符流的形式去读取,这样更方面的我们进行直接处理。\n\n比如说我们需要在一个大文本文件中查找某个字符串,可以直接通过字符流来读取判断。\n\n处理视频文件时,通常使用字节流(如 Java 中的`FileInputStream`、`FileOutputStream`)来读取或写入数据,并且会尽量使用缓冲流(如`BufferedInputStream`、`BufferedOutputStream`)来提高读写效率。\n\n在项目中,对于文本,比如说文章和教程内容,是直接存储在数据库中的,而对于视频和图片等大文件,是存储在 OSS 中的。\n\n因此,无论是文本文件还是视频文件,它们在物理存储层面都是以字节流的形式存在。区别在于,我们如何通过 Java 代码来解释和处理这些字节流:作为编码后的字符还是作为二进制数据。"
+ },
+ {
+ "id": 46,
+ "question": "BIO、NIO、AIO 之间的区别?",
+ "answer": "Java 常见的 IO 模型有三种:BIO、NIO 和 AIO。\n\n\n\nBIO:采用阻塞式 I/O 模型,线程在执行 I/O 操作时被阻塞,无法处理其他任务,适用于连接数较少的场景。\n\nNIO:采用非阻塞 I/O 模型,线程在等待 I/O 时可执行其他任务,通过 Selector 监控多个 Channel 上的事件,适用于连接数多但连接时间短的场景。\n\nAIO:使用异步 I/O 模型,线程发起 I/O 请求后立即返回,当 I/O 操作完成时通过回调函数通知线程,适用于连接数多且连接时间长的场景。\n\n#### [简单说一下 BIO?](#简单说一下-bio)\n\nBIO,也就是传统的 IO,基于字节流或字符流(如 FileInputStream、BufferedReader 等)进行文件读写,基于 Socket 和 ServerSocket 进行网络通信。\n\n对于每个连接,都需要创建一个独立的线程来处理读写操作。\n\n\n\n#### [简单说下 NIO?](#简单说下-nio)\n\nNIO,JDK 1.4 时引入,放在 java.nio 包下,提供了 Channel、Buffer、Selector 等新的抽象,基于 RandomAccessFile、FileChannel、ByteBuffer 进行文件读写,基于 SocketChannel 和 ServerSocketChannel 进行网络通信。\n\n实际上,“旧”的 I/O 包已经使用 NIO 重新实现过,所以在进行文件读写时,NIO 并无法体现出比 BIO 更可靠的性能。\n\nNIO 的魅力主要体现在网络编程中,服务器可以用一个线程处理多个客户端连接,通过 Selector 监听多个 Channel 来实现多路复用,极大地提高了网络编程的性能。\n\n\n\n缓冲区 Buffer 也能极大提升一次 IO 操作的效率。\n\n\n\n#### [简单说下 AIO?](#简单说下-aio)\n\nAIO 是 Java 7 引入的,放在 java.nio.channels 包下,提供了 AsynchronousFileChannel、AsynchronousSocketChannel 等异步 Channel。\n\n它引入了异步通道的概念,使得 I/O 操作可以异步进行。这意味着线程发起一个读写操作后不必等待其完成,可以立即进行其他任务,并且当读写操作真正完成时,线程会被异步地通知。\n\n\n```java\nAsynchronousFileChannel fileChannel = AsynchronousFileChannel.open(Paths.get(\"test.txt\"), StandardOpenOption.READ);\nByteBuffer buffer = ByteBuffer.allocate(1024);\nFuture result = fileChannel.read(buffer, 0);\nwhile (!result.isDone()) {\n // do something\n}\n```"
+ }
+ ]
+ },
+ {
+ "id": 9,
+ "categoryName": "序列化",
+ "questions": [
+ {
+ "id": 47,
+ "question": "什么是序列化?什么是反序列化?",
+ "answer": "序列化(Serialization)是指将对象转换为字节流的过程,以便能够将该对象保存到文件、数据库,或者进行网络传输。\n\n反序列化(Deserialization)就是将字节流转换回对象的过程,以便构建原始对象。\n\n\n\n#### [Serializable 接口有什么用?](#serializable-接口有什么用)\n\n`Serializable`接口用于标记一个类可以被序列化。\n\n\n```java\npublic class Person implements Serializable {\n private String name;\n private int age;\n // 省略 getter 和 setter 方法\n}\n```\n\n\n#### [serialVersionUID 有什么用?](#serialversionuid-有什么用)\n\nserialVersionUID 是 Java 序列化机制中用于标识类版本的唯一标识符。它的作用是确保在序列化和反序列化过程中,类的版本是兼容的。\n\n\n```java\nimport java.io.Serializable;\n\npublic class MyClass implements Serializable {\n private static final long serialVersionUID = 1L;\n private String name;\n private int age;\n\n // getters and setters\n}\n```\n\n\nserialVersionUID 被设置为 1L 是一种比较省事的做法,也可以使用 Intellij IDEA 进行自动生成。\n\n但只要 serialVersionUID 在序列化和反序列化过程中保持一致,就不会出现问题。\n\n如果不显式声明 serialVersionUID,Java 运行时会根据类的详细信息自动生成一个 serialVersionUID。那么当类的结构发生变化时,自动生成的 serialVersionUID 就会发生变化,导致反序列化失败。\n\n#### [Java 序列化不包含静态变量吗?](#java-序列化不包含静态变量吗)\n\n是的,序列化机制只会保存对象的状态,而静态变量属于类的状态,不属于对象的状态。\n\n#### [如果有些变量不想序列化,怎么办?](#如果有些变量不想序列化-怎么办)\n\n可以使用`transient`关键字修饰不想序列化的变量。\n\n\n```java\npublic class Person implements Serializable {\n private String name;\n private transient int age;\n // 省略 getter 和 setter 方法\n}\n```\n\n\n#### [能解释一下序列化的过程和作用吗?](#能解释一下序列化的过程和作用吗)\n\n序列化过程通常涉及到以下几个步骤:\n\n第一步,实现 Serializable 接口。\n\n\n```java\npublic class Person implements Serializable {\n private String name;\n private int age;\n\n // 省略构造方法、getters和setters\n}\n```\n\n\n第二步,使用 ObjectOutputStream 来将对象写入到输出流中。\n\n\n```java\nObjectOutputStream out = new ObjectOutputStream(new FileOutputStream(\"person.ser\"));\n```\n\n\n第三步,调用 ObjectOutputStream 的 writeObject 方法,将对象序列化并写入到输出流中。\n\n\n```java\nPerson person = new Person(\"练习伴侣二\", 18);\nout.writeObject(person);\n```"
+ },
+ {
+ "id": 48,
+ "question": "说说有几种序列化方式?",
+ "answer": "Java 序列化方式有很多,常见的有三种:\n\n\n\n* Java 对象序列化 :Java 原生序列化方法即通过 Java 原生流(InputStream 和 OutputStream 之间的转化)的方式进行转化,一般是对象输出流 `ObjectOutputStream`和对象输入流`ObjectInputStream`。\n* Json 序列化:这个可能是我们最常用的序列化方式,Json 序列化的选择很多,一般会使用 jackson 包,通过 ObjectMapper 类来进行一些操作,比如将对象转化为 byte 数组或者将 json 串转化为对象。\n* ProtoBuff 序列化:ProtocolBuffer 是一种轻便高效的结构化数据存储格式,ProtoBuff 序列化对象可以很大程度上将其压缩,可以大大减少数据传输大小,提高系统性能。"
+ }
+ ]
+ },
+ {
+ "id": 10,
+ "categoryName": "网络编程",
+ "questions": [
+ {
+ "id": 49,
+ "question": "了解过Socket网络套接字吗?(补充)",
+ "answer": "> 2024 年 11 月 28 日 增补\n\nSocket 是网络通信的基础,表示两台设备之间通信的一个端点。Socket 通常用于建立 TCP 或 UDP 连接,实现进程间的网络通信。\n\n\n\n一个简单的 TCP 客户端:\n\n\n```java\nclass TcpClient {\n public static void main(String[] args) throws IOException {\n Socket socket = new Socket(\"127.0.0.1\", 8080); // 连接服务器\n BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));\n PrintWriter out = new PrintWriter(socket.getOutputStream(), true);\n\n out.println(\"Hello, Server!\"); // 发送消息\n System.out.println(\"Server response: \" + in.readLine()); // 接收服务器响应\n\n socket.close();\n }\n}\n```\n\n\nTCP 服务端:\n\n\n```java\nclass TcpServer {\n public static void main(String[] args) throws IOException {\n ServerSocket serverSocket = new ServerSocket(8080); // 创建服务器端Socket\n System.out.println(\"Server started, waiting for connection...\");\n Socket socket = serverSocket.accept(); // 等待客户端连接\n System.out.println(\"Client connected: \" + socket.getInetAddress());\n\n BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));\n PrintWriter out = new PrintWriter(socket.getOutputStream(), true);\n\n String message;\n while ((message = in.readLine()) != null) {\n System.out.println(\"Received: \" + message);\n out.println(\"Echo: \" + message); // 回送消息\n }\n\n socket.close();\n serverSocket.close();\n }\n}\n```\n\n\n#### [RPC框架了解吗?](#rpc框架了解吗)\n\nRPC是一种协议,允许程序调用位于远程服务器上的方法,就像调用本地方法一样。RPC 通常基于 Socket 通信实现。\n\n> RPC,Remote Procedure Call,远程过程调用\n\nRPC 框架支持高效的序列化(如 Protocol Buffers)和通信协议(如 HTTP/2),屏蔽了底层网络通信的细节,开发者只需关注业务逻辑即可。\n\n\n\n常见的 RPC 框架包括:\n\n1. gRPC:基于 HTTP/2 和 Protocol Buffers。\n2. Dubbo:阿里开源的分布式 RPC 框架,适合微服务场景。\n3. Spring Cloud OpenFeign:基于 REST 的轻量级 RPC 框架。\n4. Thrift:Apache 的跨语言 RPC 框架,支持多语言代码生成。"
+ }
+ ]
+ },
+ {
+ "id": 11,
+ "categoryName": "泛型",
+ "questions": [
+ {
+ "id": 50,
+ "question": "Java 泛型了解么?",
+ "answer": "泛型主要用于提高代码的类型安全,它允许在定义类、接口和方法时使用类型参数,这样可以在编译时检查类型一致性,避免不必要的类型转换和类型错误。\n\n没有泛型的时候,像 List 这样的集合类存储的是 Object 类型,导致从集合中读取数据时,必须进行强制类型转换,否则会引发 ClassCastException。\n\n\n```java\nList list = new ArrayList();\nlist.add(\"hello\");\nString str = (String) list.get(0); // 必须强制类型转换\n```\n\n\n泛型一般有三种使用方式:**泛型类**、**泛型接口**、**泛型方法**。\n\n\n\n**1.泛型类**:\n\n\n```java\n//此处T可以随便写为任意标识,常见的如T、E、K、V等形式的参数常用于表示泛型\n//在实例化泛型类时,必须指定T的具体类型\npublic class Generic{\n\n private T key;\n\n public Generic(T key) {\n this.key = key;\n }\n\n public T getKey(){\n return key;\n }\n}\n```\n\n\n如何实例化泛型类:\n\n\n```java\nGeneric genericInteger = new Generic(123456);\n```\n\n\n**2.泛型接口** :\n\n\n```java\npublic interface Generator {\n public T method();\n}\n```\n\n\n实现泛型接口,指定类型:\n\n\n```java\nclass GeneratorImpl implements Generator{\n @Override\n public String method() {\n return \"hello\";\n }\n}\n```\n\n\n**3.泛型方法** :\n\n\n```java\n public static < E > void printArray( E[] inputArray )\n {\n for ( E element : inputArray ){\n System.out.printf( \"%s \", element );\n }\n System.out.println();\n }\n```\n\n\n使用:\n\n\n```java\n// 创建不同类型数组: Integer, Double 和 Character\nInteger[] intArray = { 1, 2, 3 };\nString[] stringArray = { \"Hello\", \"World\" };\nprintArray( intArray );\nprintArray( stringArray );\n```\n\n\n#### [泛型常用的通配符有哪些?](#泛型常用的通配符有哪些)\n\n**常用的通配符为: T,E,K,V,?**\n\n* ? 表示不确定的 java 类型\n* T (type) 表示具体的一个 java 类型\n* K V (key value) 分别代表 java 键值中的 Key Value\n* E (element) 代表 Element\n\n#### [什么是泛型擦除?](#什么是泛型擦除)\n\n所谓的泛型擦除,官方名叫“类型擦除”。\n\nJava 的泛型是伪泛型,这是因为 Java 在编译期间,所有的类型信息都会被擦掉。\n\n也就是说,在运行的时候是没有泛型的。\n\n例如这段代码,往一群猫里放条狗:\n\n\n```java\nLinkedList cats = new LinkedList();\nLinkedList list = cats; // 注意我在这里把范型去掉了,但是list和cats是同一个链表!\nlist.add(new Dog()); // 完全没问题!\n```\n\n\n因为 Java 的范型只存在于源码里,编译的时候给你静态地检查一下范型类型是否正确,而到了运行时就不检查了。上面这段代码在 JRE(Java**运行**环境)看来和下面这段没区别:\n\n\n```java\nLinkedList cats = new LinkedList(); // 注意:没有范型!\nLinkedList list = cats;\nlist.add(new Dog());\n```\n\n\n#### [为什么要类型擦除呢?](#为什么要类型擦除呢)\n\n主要是为了向下兼容,因为 JDK5 之前是没有泛型的,为了让 JVM 保持向下兼容,就出了类型擦除这个策略。"
+ }
+ ]
+ },
+ {
+ "id": 12,
+ "categoryName": "注解",
+ "questions": [
+ {
+ "id": 51,
+ "question": "说一下你对注解的理解?",
+ "answer": "**Java 注解本质上是一个标记**,可以理解成生活中的一个人的一些小装扮,比如戴什么什么帽子,戴什么眼镜。\n\n\n\n注解可以标记在类上、方法上、属性上等,标记自身也可以设置一些值,比如帽子颜色是绿色。\n\n有了标记之后,我们就可以在编译或者运行阶段去识别这些标记,然后搞一些事情,这就是注解的用处。\n\n例如我们常见的 AOP,使用注解作为切点就是运行期注解的应用;比如 lombok,就是注解在编译期的运行。\n\n注解生命周期有三大类,分别是:\n\n* RetentionPolicy.SOURCE:给编译器用的,不会写入 class 文件\n* RetentionPolicy.CLASS:会写入 class 文件,在类加载阶段丢弃,也就是运行的时候就没这个信息了\n* RetentionPolicy.RUNTIME:会写入 class 文件,永久保存,可以通过反射获取注解信息\n\n所以我上文写的是解析的时候,没写具体是解析啥,因为不同的生命周期的解析动作是不同的。\n\n像常见的:\n\n\n\n就是给编译器用的,编译器编译的时候检查没问题就 over 了,class 文件里面不会有 Override 这个标记。\n\n再比如 Spring 常见的 Autowired ,就是 RUNTIME 的,所以**在运行的时候可以通过反射得到注解的信息**,还能拿到标记的值 required 。\n\n"
+ }
+ ]
+ },
+ {
+ "id": 13,
+ "categoryName": "反射",
+ "questions": [
+ {
+ "id": 52,
+ "question": "什么是反射?应用?原理?",
+ "answer": "反射允许 Java 在运行时检查和操作类的方法和字段。通过反射,可以动态地获取类的字段、方法、构造方法等信息,并在运行时调用方法或访问字段。\n\n比如创建一个对象是通过 new 关键字来实现的:\n\n\n```java\nPerson person = new Person();\n```\n\n\nPerson 类的信息在编译时就确定了,那假如在编译期无法确定类的信息,但又想在运行时获取类的信息、创建类的实例、调用类的方法,这时候就要用到反射。\n\n反射功能主要通过 `java.lang.Class` 类及 `java.lang.reflect` 包中的类如 Method, Field, Constructor 等来实现。\n\n\n\n比如说我们可以装来动态加载类并创建对象:\n\n\n```java\nString className = \"java.util.Date\";\nClass> cls = Class.forName(className);\nObject obj = cls.newInstance();\nSystem.out.println(obj.getClass().getName());\n```\n\n\n比如说我们可以这样来访问字段和方法:\n\n\n```java\n// 加载并实例化类\nClass> cls = Class.forName(\"java.util.Date\");\nObject obj = cls.newInstance();\n\n// 获取并调用方法\nMethod method = cls.getMethod(\"getTime\");\nObject result = method.invoke(obj);\nSystem.out.println(\"Time: \" + result);\n\n// 访问字段\nField field = cls.getDeclaredField(\"fastTime\");\nfield.setAccessible(true); // 对于私有字段需要这样做\nSystem.out.println(\"fastTime: \" + field.getLong(obj));\n```\n\n\n#### [反射有哪些应用场景?](#反射有哪些应用场景)\n\n①、Spring 框架就大量使用了反射来动态加载和管理 Bean。\n\n\n```java\nClass> clazz = Class.forName(\"com.example.MyClass\");\nObject instance = clazz.newInstance();\n```\n\n\n②、Java 的动态代理(Dynamic Proxy)机制就使用了反射来创建代理类。代理类可以在运行时动态处理方法调用,这在实现 AOP 和拦截器时非常有用。\n\n\n```java\nInvocationHandler handler = new MyInvocationHandler();\nMyInterface proxyInstance = (MyInterface) Proxy.newProxyInstance(\n MyInterface.class.getClassLoader(),\n new Class>[] { MyInterface.class },\n handler\n);\n```\n\n\n③、JUnit 和 TestNG 等测试框架使用反射机制来发现和执行测试方法。反射允许框架扫描类,查找带有特定注解(如 `@Test`)的方法,并在运行时调用它们。\n\n\n```java\nMethod testMethod = testClass.getMethod(\"testSomething\");\ntestMethod.invoke(testInstance);\n```\n\n\n#### [反射的原理是什么?](#反射的原理是什么)\n\nJava 程序的执行分为编译和运行两步,编译之后会生成字节码(.class)文件,JVM 进行类加载的时候,会加载字节码文件,将类型相关的所有信息加载进方法区,反射就是去获取这些信息,然后进行各种操作。"
+ }
+ ]
+ },
+ {
+ "id": 14,
+ "categoryName": "JDK1.8 新特性",
+ "questions": [
+ {
+ "id": 53,
+ "question": "JDK 1.8 都有哪些新特性?",
+ "answer": "JDK 1.8 新增了不少新的特性,如 Lambda 表达式、接口默认方法、Stream API、日期时间 API、Optional 类等。\n\n\n\n①、Java 8 允许在接口中添加默认方法和静态方法。\n\n\n```java\npublic interface MyInterface {\n default void myDefaultMethod() {\n System.out.println(\"My default method\");\n }\n\n static void myStaticMethod() {\n System.out.println(\"My static method\");\n }\n}\n```\n\n\n②、Lambda 表达式描述了一个代码块(或者叫匿名方法),可以将其作为参数传递给构造方法或者普通方法以便后续执行。\n\n\n```java\npublic class LamadaTest {\n public static void main(String[] args) {\n new Thread(() -> System.out.println(\"练习伴侣二\")).start();\n }\n}\n```\n\n\n《Effective Java》的作者 Josh Bloch 建议使用 Lambda 表达式时,最好不要超过 3 行。否则代码可读性会变得很差。\n\n③、Stream 是对 Java 集合框架的增强,它提供了一种高效且易于使用的数据处理方式。\n\n\n```java\nList list = new ArrayList<>();\nlist.add(\"中国加油\");\nlist.add(\"世界加油\");\nlist.add(\"世界加油\");\n\nlong count = list.stream().distinct().count();\nSystem.out.println(count);\n```\n\n\n④、Java 8 引入了一个全新的日期和时间 API,位于`java.time`包中。这个新的 API 纠正了旧版`java.util.Date`类中的许多缺陷。\n\n\n```java\nLocalDate today = LocalDate.now();\nSystem.out.println(\"Today's Local date : \" + today);\n\nLocalTime time = LocalTime.now();\nSystem.out.println(\"Local time : \" + time);\n\nLocalDateTime now = LocalDateTime.now();\nSystem.out.println(\"Current DateTime : \" + now);\n```\n\n\n⑤、引入 Optional 是为了减少空指针异常。\n\n\n```java\nOptional optional = Optional.of(\"练习伴侣二\");\noptional.isPresent(); // true\noptional.get(); // \"练习伴侣二\"\noptional.orElse(\"练习伴侣三\"); // \"bam\"\noptional.ifPresent((s) -> System.out.println(s.charAt(0))); // \"沉\"\n```"
+ },
+ {
+ "id": 54,
+ "question": "Lambda 表达式了解多少?",
+ "answer": "Lambda 表达式主要用于提供一种简洁的方式来表示匿名方法,使 Java 具备了函数式编程的特性。\n\n比如说我们可以使用 Lambda 表达式来简化线程的创建:\n\n\n```java\nnew Thread(() -> System.out.println(\"Hello World\")).start();\n```\n\n\n这比以前的匿名内部类要简洁很多。\n\n所谓的函数式编程,就是把函数作为参数传递给方法,或者作为方法的结果返回。比如说我们可以配合 Stream 流进行数据过滤:\n\n\n```java\nList numbers = Arrays.asList(1, 2, 3, 4, 5, 6);\nList evenNumbers = numbers.stream()\n .filter(n -> n % 2 == 0)\n .collect(Collectors.toList());\n```\n\n\n其中 `n -> n % 2 == 0` 就是一个 Lambda 表达式。表示传入一个参数 n,返回 `n % 2 == 0` 的结果。\n\n#### [Java8 有哪些内置函数式接口?](#java8-有哪些内置函数式接口)\n\nJDK 1.8 API 包含了很多内置的函数式接口。其中就包括我们在老版本中经常见到的 **Comparator** 和 **Runnable**,Java 8 为他们都添加了 @FunctionalInterface 注解,以用来支持 Lambda 表达式。\n\n除了这两个之外,还有 Callable、Predicate、Function、Supplier、Consumer 等等。"
+ },
+ {
+ "id": 55,
+ "question": "Optional 了解吗?",
+ "answer": "`Optional`是用于防范`NullPointerException`。\n\n可以将 `Optional` 看做是包装对象(可能是 `null`, 也有可能非 `null`)的容器。当我们定义了 一个方法,这个方法返回的对象可能是空,也有可能非空的时候,我们就可以考虑用 `Optional` 来包装它,这也是在 Java 8 被推荐使用的做法。\n\n\n```java\nOptional optional = Optional.of(\"bam\");\n\noptional.isPresent(); // true\noptional.get(); // \"bam\"\noptional.orElse(\"fallback\"); // \"bam\"\n\noptional.ifPresent((s) -> System.out.println(s.charAt(0))); // \"b\"\n```"
+ },
+ {
+ "id": 56,
+ "question": "Stream 流用过吗?",
+ "answer": "`Stream` 流,简单来说,使用 `java.util.Stream` 对一个包含一个或多个元素的集合做各种操作。这些操作可能是 *中间操作* 亦或是 *终端操作*。 终端操作会返回一个结果,而中间操作会返回一个 `Stream` 流。\n\nStream 流一般用于集合,我们对一个集合做几个常见操作:\n\n\n```java\nList stringCollection = new ArrayList<>();\nstringCollection.add(\"ddd2\");\nstringCollection.add(\"aaa2\");\nstringCollection.add(\"bbb1\");\nstringCollection.add(\"aaa1\");\nstringCollection.add(\"bbb3\");\nstringCollection.add(\"ccc\");\nstringCollection.add(\"bbb2\");\nstringCollection.add(\"ddd1\");\n```\n\n\n* **Filter 过滤**\n\n\n```java\nstringCollection\n .stream()\n .filter((s) -> s.startsWith(\"a\"))\n .forEach(System.out::println);\n\n// \"aaa2\", \"aaa1\"\n```\n\n\n* **Sorted 排序**\n\n\n```java\nstringCollection\n .stream()\n .sorted()\n .filter((s) -> s.startsWith(\"a\"))\n .forEach(System.out::println);\n\n// \"aaa1\", \"aaa2\"\n```\n\n\n* **Map 转换**\n\n\n```java\nstringCollection\n .stream()\n .map(String::toUpperCase)\n .sorted((a, b) -> b.compareTo(a))\n .forEach(System.out::println);\n\n// \"DDD2\", \"DDD1\", \"CCC\", \"BBB3\", \"BBB2\", \"AAA2\", \"AAA1\"\n```\n\n\n* **Match 匹配**\n\n\n```java\n// 验证 list 中 string 是否有以 a 开头的, 匹配到第一个,即返回 true\nboolean anyStartsWithA =\n stringCollection\n .stream()\n .anyMatch((s) -> s.startsWith(\"a\"));\n\nSystem.out.println(anyStartsWithA); // true\n\n// 验证 list 中 string 是否都是以 a 开头的\nboolean allStartsWithA =\n stringCollection\n .stream()\n .allMatch((s) -> s.startsWith(\"a\"));\n\nSystem.out.println(allStartsWithA); // false\n\n// 验证 list 中 string 是否都不是以 z 开头的,\nboolean noneStartsWithZ =\n stringCollection\n .stream()\n .noneMatch((s) -> s.startsWith(\"z\"));\n\nSystem.out.println(noneStartsWithZ); // true\n```\n\n\n* **Count 计数**\n\n`count` 是一个终端操作,它能够统计 `stream` 流中的元素总数,返回值是 `long` 类型。\n\n\n```java\n// 先对 list 中字符串开头为 b 进行过滤,让后统计数量\nlong startsWithB =\n stringCollection\n .stream()\n .filter((s) -> s.startsWith(\"b\"))\n .count();\n\nSystem.out.println(startsWithB); // 3\n```\n\n\n* **Reduce**\n\n`Reduce` 中文翻译为:*减少、缩小*。通过入参的 `Function`,我们能够将 `list` 归约成一个值。它的返回类型是 `Optional` 类型。\n\n\n```java\nOptional reduced =\n stringCollection\n .stream()\n .sorted()\n .reduce((s1, s2) -> s1 + \"#\" + s2);\n\nreduced.ifPresent(System.out::println);\n// \"aaa1#aaa2#bbb1#bbb2#bbb3#ccc#ddd1#ddd2\"\n```\n\n\n以上是常见的几种流式操作,还有其它的一些流式操作,可以帮助我们更便捷地处理集合数据。\n\n\n> 2024 年 12 月 30 日第二版优化结束。"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": 2,
+ "topicName": "Java集合",
+ "categories": [
+ {
+ "id": 15,
+ "categoryName": "引言",
+ "questions": [
+ {
+ "id": 57,
+ "question": "说说有哪些常见的集合框架?",
+ "answer": "\n\n集合框架可以分为两条大的支线:\n\n①、第一条支线 Collection,主要由 List、Set、Queue 组成:\n\n* List 代表有序、可重复的集合,典型代表就是封装了动态数组的 和封装了链表的 ;\n* Set 代表无序、不可重复的集合,典型代表就是 HashSet 和 TreeSet;\n* Queue 代表队列,典型代表就是双端队列 ,以及优先级队列 。\n\n②、第二条支线 Map,代表键值对的集合,典型代表就是 。\n\n另外一个回答版本:\n\n①、Collection 接口:最基本的集合框架表示方式,提供了添加、删除、清空等基本操作,它主要有三个子接口:\n\n* `List`:一个有序的集合,可以包含重复的元素。实现类包括 ArrayList、LinkedList 等。\n* `Set`:一个不包含重复元素的集合。实现类包括 HashSet、LinkedHashSet、TreeSet 等。\n* `Queue`:一个用于保持元素队列的集合。实现类包括 PriorityQueue、ArrayDeque 等。\n\n②、`Map` 接口:表示键值对的集合,一个键映射到一个值。键不能重复,每个键只能对应一个值。Map 接口的实现类包括 HashMap、LinkedHashMap、TreeMap 等。\n\n#### [集合框架有哪几个常用工具类?](#集合框架有哪几个常用工具类)\n\n集合框架位于 java.util 包下,提供了两个常用的工具类:\n\n* :提供了一些对集合进行排序、二分查找、同步的静态方法。\n* :提供了一些对数组进行排序、打印、和 List 进行转换的静态方法。\n\n#### [简单介绍一下队列](#简单介绍一下队列)\n\nJava 中的队列主要通过 Queue 接口和并发包下的 BlockingQueue 两个接口来实现。\n\n优先级队列 PriorityQueue 实现了 Queue 接口,是一个无界队列,它的元素按照自然顺序排序或者 Comparator 比较器进行排序。\n\n\n\n双端队列 ArrayDeque 也实现了 Queue 接口,是一个基于数组的,可以在两端插入和删除元素的队列。\n\n\n\nLinkedList 实现了 Queue 接口的子类 Deque,所以也可以当做双端队列来使用。\n\n\n\n#### [用过哪些集合类,它们的优劣?](#用过哪些集合类-它们的优劣)\n\n我常用的集合类有 ArrayList、LinkedList、HashMap、LinkedHashMap。\n\n1. ArrayList 可以看作是一个动态数组,可以在需要时动态扩容数组的容量,只不过需要复制元素到新的数组。优点是访问速度快,可以通过索引直接查找到元素。缺点是插入和删除元素可能需要移动或者复制元素。\n2. LinkedList 是一个双向链表,适合频繁的插入和删除操作。优点是插入和删除元素的时候只需要改变节点的前后指针,缺点是访问元素时需要遍历链表。\n3. HashMap 是一个基于哈希表的键值对集合。优点是可以根据键的哈希值快速查找到值,但有可能会发生哈希冲突,并且不保留键值对的插入顺序。\n4. LinkedHashMap 在 HashMap 的基础上增加了一个双向链表来保持键值对的插入顺序。\n\n#### [队列和栈的区别了解吗?](#队列和栈的区别了解吗)\n\n队列是一种先进先出(FIFO, First-In-First-Out)的数据结构,第一个加入队列的元素会成为第一个被移除的元素。\n\n\n\n栈是一种后进先出(LIFO, Last-In-First-Out)的数据结构,最后一个加入栈的元素会成为第一个被移除的元素。\n\n\n\n#### [哪些是线程安全的容器?](#哪些是线程安全的容器)\n\n像 Vector、Hashtable、ConcurrentHashMap、CopyOnWriteArrayList、ConcurrentLinkedQueue、ArrayBlockingQueue、LinkedBlockingQueue 都是线程安全的。\n\n#### [Collection 继承了哪些接口?](#collection-继承了哪些接口)\n\nCollection 继承了 Iterable 接口,这意味着所有实现 Collection 接口的类都必须实现 `iterator()` 方法,之后就可以使用增强型 for 循环遍历集合中的元素了。\n\n"
+ }
+ ]
+ },
+ {
+ "id": 16,
+ "categoryName": "List",
+ "questions": [
+ {
+ "id": 58,
+ "question": "ArrayList 和 LinkedList 有什么区别?",
+ "answer": "ArrayList 是基于数组实现的,LinkedList 是基于链表实现的。\n\n\n\n#### [ArrayList 和 LinkedList 的用途有什么不同?](#arraylist-和-linkedlist-的用途有什么不同)\n\n多数情况下,ArrayList 更利于查找,LinkedList 更利于增删。\n\n①、由于 ArrayList 是基于数组实现的,所以 `get(int index)` 可以直接通过数组下标获取,时间复杂度是 O(1);LinkedList 是基于链表实现的,`get(int index)` 需要遍历链表,时间复杂度是 O(n)。\n\n当然,`get(E element)` 这种查找,两种集合都需要遍历通过 equals 比较获取元素,所以时间复杂度都是 O(n)。\n\n②、ArrayList 如果增删的是数组的尾部,时间复杂度是 O(1);如果 add 的时候涉及到扩容,时间复杂度会上升到 O(n)。\n\n但如果插入的是中间的位置,就需要把插入位置后的元素向前或者向后移动,甚至还有可能触发扩容,效率就会低很多,变成 O(n)。\n\n\n\nLinkedList 因为是链表结构,插入和删除只需要改变前置节点、后置节点和插入节点的引用,因此不需要移动元素。\n\n如果是在链表的头部插入或者删除,时间复杂度是 O(1);如果是在链表的中间插入或者删除,时间复杂度是 O(n),因为需要遍历链表找到插入位置;如果是在链表的尾部插入或者删除,时间复杂度是 O(1)。\n\n\n\n#### [ArrayList 和 LinkedList 是否支持随机访问?](#arraylist-和-linkedlist-是否支持随机访问)\n\n①、ArrayList 是基于数组的,也实现了 RandomAccess 接口,所以它支持随机访问,可以通过下标直接获取元素。\n\n\n\n②、LinkedList 是基于链表的,所以它没法根据下标直接获取元素,不支持随机访问。\n\n\n\n#### [ArrayList 和 LinkedList 内存占用有何不同?](#arraylist-和-linkedlist-内存占用有何不同)\n\nArrayList 是基于数组的,是一块连续的内存空间,所以它的内存占用是比较紧凑的;但如果涉及到扩容,就会重新分配内存,空间是原来的 1.5 倍。\n\n\n\nLinkedList 是基于链表的,每个节点都有一个指向下一个节点和上一个节点的引用,于是每个节点占用的内存空间比 ArrayList 稍微大一点。\n\n#### [ArrayList 和 LinkedList 的使用场景有什么不同?](#arraylist-和-linkedlist-的使用场景有什么不同)\n\nArrayList 适用于:\n\n* 随机访问频繁:需要频繁通过索引访问元素的场景。\n* 读取操作远多于写入操作:如存储不经常改变的列表。\n* 末尾添加元素:需要频繁在列表末尾添加元素的场景。\n\nLinkedList 适用于:\n\n* 频繁插入和删除:在列表中间频繁插入和删除元素的场景。\n* 不需要快速随机访问:顺序访问多于随机访问的场景。\n* 队列和栈:由于其双向链表的特性,LinkedList 可以实现队列(FIFO)和栈(LIFO)。\n\n#### [链表和数组有什么区别?](#链表和数组有什么区别)\n\n* 数组在内存中占用的是一块连续的存储空间,因此我们可以通过数组下标快速访问任意元素。数组在创建时必须指定大小,一旦分配内存,数组的大小就固定了。\n* 链表的元素存储在于内存中的任意位置,每个节点通过指针指向下一个节点。\n\n"
+ },
+ {
+ "id": 59,
+ "question": "ArrayList 的扩容机制了解吗?",
+ "answer": "了解。当往 ArrayList 中添加元素时,会先检查是否需要扩容,如果当前容量+1 超过数组长度,就会进行扩容。\n\n\n\n扩容后的新数组长度是原来的 1.5 倍,然后再把原数组的值拷贝到新数组中。\n\n\n```java\nprivate void grow(int minCapacity) {\n // overflow-conscious code\n int oldCapacity = elementData.length;\n int newCapacity = oldCapacity + (oldCapacity >> 1);\n if (newCapacity - minCapacity < 0)\n newCapacity = minCapacity;\n if (newCapacity - MAX_ARRAY_SIZE > 0)\n newCapacity = hugeCapacity(minCapacity);\n // minCapacity is usually close to size, so this is a win:\n elementData = Arrays.copyOf(elementData, newCapacity);\n}\n```"
+ },
+ {
+ "id": 60,
+ "question": "ArrayList 怎么序列化的知道吗?",
+ "answer": "在 ArrayList 中,writeObject 方法被重写了,用于自定义序列化逻辑:只序列化有效数据,因为 elementData 数组的容量一般大于实际的元素数量,声明的时候也加了 transient 关键字。\n\n\n\n#### [为什么 ArrayList 不直接序列化元素数组呢?](#为什么-arraylist-不直接序列化元素数组呢)\n\n出于效率的考虑,数组可能长度 100,但实际只用了 50,剩下的 50 没用到,也就不需要序列化。\n\n\n```java\nprivate void writeObject(java.io.ObjectOutputStream s)\n throws java.io.IOException {\n // 将当前 ArrayList 的结构进行序列化\n int expectedModCount = modCount;\n s.defaultWriteObject(); // 序列化非 transient 字段\n // 序列化数组的大小\n s.writeInt(size);\n // 序列化每个元素\n for (int i = 0; i < size; i++) {\n s.writeObject(elementData[i]);\n }\n // 检查是否在序列化期间发生了并发修改\n if (modCount != expectedModCount) {\n throw new ConcurrentModificationException();\n }\n}\n```"
+ },
+ {
+ "id": 61,
+ "question": "快速失败fail-fast了解吗?",
+ "answer": "fail—fast 是 Java 集合的一种错误检测机制。\n\n在用迭代器遍历集合对象时,如果线程 A 遍历过程中,线程 B 对集合对象的内容进行了修改,就会抛出 Concurrent Modification Exception。\n\n迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 `modCount` 变量。集合在被遍历期间如果内容发生变化,就会改变`modCount`的值。每当迭代器使用 `hashNext()/next()`遍历下一个元素之前,都会检测 modCount 变量是否为 expectedmodCount 值,是的话就返回遍历;否则抛出异常,终止遍历。\n\n异常的抛出条件是检测到 `modCount!=expectedmodCount` 这个条件。如果集合发生变化时修改 modCount 值刚好又设置为了 expectedmodCount 值,则异常不会抛出。因此,不能依赖于这个异常是否抛出而进行并发操作的编程,这个异常只建议用于检测并发修改的 bug。\n\njava.util 包下的集合类都是快速失败的,不能在多线程下发生并发修改(迭代过程中被修改),比如 ArrayList 类。\n\n#### [什么是安全失败(fail—safe)呢?](#什么是安全失败-fail—safe-呢)\n\n采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。\n\n原理:由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,所以不会触发 Concurrent Modification Exception。\n\n缺点:基于拷贝内容的优点是避免了 Concurrent Modification Exception,但同样地,迭代器并不能访问到修改后的内容,即:迭代器遍历的是开始遍历那一刻拿到的集合拷贝,在遍历期间原集合发生的修改迭代器是不知道的。\n\n场景:java.util.concurrent 包下的容器都是安全失败,可以在多线程下并发使用,并发修改,比如 CopyOnWriteArrayList 类。"
+ },
+ {
+ "id": 62,
+ "question": "有哪几种实现 ArrayList 线程安全的方法?",
+ "answer": "常用的有两种。\n\n可以使用 `Collections.synchronizedList()` 方法,它可以返回一个线程安全的 List。\n\n\n```java\nSynchronizedList list = Collections.synchronizedList(new ArrayList());\n```\n\n\n内部是通过 加锁来实现的。\n\n也可以直接使用 ,它是线程安全的 ArrayList,遵循写时复制的原则,每当对列表进行修改时,都会创建一个新副本,这个新副本会替换旧的列表,而对旧列表的所有读取操作仍然在原有的列表上进行。\n\n\n```java\nCopyOnWriteArrayList list = new CopyOnWriteArrayList();\n```\n\n\n通俗的讲,CopyOnWrite 就是当我们往一个容器添加元素的时候,不直接往容器中添加,而是先复制出一个新的容器,然后在新的容器里添加元素,添加完之后,再将原容器的引用指向新的容器。多个线程在读的时候,不需要加锁,因为当前容器不会添加任何元素。这样就实现了线程安全。\n\n#### [ArrayList 和 Vector 的区别?](#arraylist-和-vector-的区别)\n\nVector 属于 JDK 1.0 时期的遗留类,不推荐使用,仍然保留着是因为 Java 希望向后兼容。\n\nArrayList 是在 JDK 1.2 时引入的,用于替代 Vector 作为主要的非同步动态数组实现。因为 Vector 所有的方法都使用了 synchronized 关键字进行同步,所以单线程环境下效率较低。\n\n"
+ },
+ {
+ "id": 63,
+ "question": "CopyOnWriteArrayList 了解多少?",
+ "answer": "CopyOnWriteArrayList 就是线程安全版本的 ArrayList。\n\n`CopyOnWrite`——写时复制,已经明示了它的原理。\n\nCopyOnWriteArrayList 采用了一种读写分离的并发策略。CopyOnWriteArrayList 容器允许并发读,读操作是无锁的。至于写操作,比如说向容器中添加一个元素,首先将当前容器复制一份,然后在新副本上执行写操作,结束之后再将原容器的引用指向新容器。\n\n"
+ }
+ ]
+ },
+ {
+ "id": 17,
+ "categoryName": "Map",
+ "questions": [
+ {
+ "id": 64,
+ "question": "能说一下 HashMap 的底层数据结构吗?",
+ "answer": "JDK 8 中 HashMap 的数据结构是`数组`+`链表`+`红黑树`。\n\n\n\n数组用来存储键值对,每个键值对可以通过索引直接拿到,索引是通过对键的哈希值进行进一步的 `hash()` 处理得到的。\n\n当多个键经过哈希处理后得到相同的索引时,需要通过链表来解决哈希冲突——将具有相同索引的键值对通过链表存储起来。\n\n不过,链表过长时,查询效率会比较低,于是当链表的长度超过 8 时(且数组的长度大于 64),链表就会转换为红黑树。红黑树的查询效率是 O(logn),比链表的 O(n) 要快。\n\n`hash()` 方法的目标是尽量减少哈希冲突,保证元素能够均匀地分布在数组的每个位置上。\n\n\n```java\nstatic final int hash(Object key) {\n int h;\n return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);\n}\n```\n\n\n如果键的哈希值已经在数组中存在,其对应的值将被新值覆盖。\n\nHashMap 的初始容量是 16,随着元素的不断添加,HashMap 就需要进行扩容,阈值是`capacity * loadFactor`,capacity 为容量,loadFactor 为负载因子,默认为 0.75。\n\n扩容后的数组大小是原来的 2 倍,然后把原来的元素重新计算哈希值,放到新的数组中。"
+ },
+ {
+ "id": 65,
+ "question": "你对红黑树了解多少?",
+ "answer": "红黑树是一种自平衡的二叉查找树:\n\n1. 每个节点要么是红色,要么是黑色;\n2. 根节点永远是黑色;\n3. 所有的叶子节点都是是黑色的(下图中的 NULL 节点);\n4. 红色节点的子节点一定是黑色的;\n5. 从任一节点到其每个叶子的所有简单路径都包含相同数目的黑色节点。\n\n\n\n#### [为什么不用二叉树?](#为什么不用二叉树)\n\n二叉树是最基本的树结构,每个节点最多有两个子节点,但是二叉树容易出现极端情况,比如插入的数据是有序的,那么二叉树就会退化成链表,查询效率就会变成 O(n)。\n\n#### [为什么不用平衡二叉树?](#为什么不用平衡二叉树)\n\n平衡二叉树比红黑树的要求更高,每个节点的左右子树的高度最多相差 1,这种高度的平衡保证了极佳的查找效率,但在进行插入和删除操作时,可能需要频繁地进行旋转来维持树的平衡,维护成本更高。\n\n#### [为什么用红黑树?](#为什么用红黑树)\n\n链表的查找时间复杂度是 `O(n)`,当链表长度较长时,查找性能会下降。红黑树是一种折中的方案,查找、插入、删除的时间复杂度都是 `O(log n)`。"
+ },
+ {
+ "id": 66,
+ "question": "红黑树怎么保持平衡的?",
+ "answer": "`旋转`和`染色`。\n\n①、通过左旋和右旋来调整树的结构,避免某一侧过深。\n\n\n\n②、染⾊,修复红黑规则,从而保证树的高度不会失衡。\n\n"
+ },
+ {
+ "id": 67,
+ "question": "HashMap 的 put 流程知道吗?",
+ "answer": "哈希寻址 → 处理哈希冲突(链表还是红黑树)→ 判断是否需要扩容 → 插入/覆盖节点。\n\n\n\n详细版:\n\n第一步,通过 hash 方法进一步扰动哈希值,以减少哈希冲突。\n\n\n```java\nstatic final int hash(Object key) {\n int h;\n return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);\n}\n```\n\n\n第二步,进行第一次的数组扩容;并使用哈希值和数组长度进行取模运算,确定索引位置。\n\n\n```java\nif ((tab = table) == null || (n = tab.length) == 0)\n n = (tab = resize()).length;\n\nif ((p = tab[i = (n - 1) & hash]) == null)\n tab[i] = newNode(hash, key, value, null);\n```\n\n\n如果当前位置为空,直接将键值对插入该位置;否则判断当前位置的第一个节点是否与新节点的 key 相同,如果相同直接覆盖 value,如果不同,说明发生哈希冲突。\n\n如果是链表,将新节点添加到链表的尾部;如果链表长度大于等于 8,则将链表转换为红黑树。\n\n\n```java\npublic V put(K key, V value) {\n return putVal(hash(key), key, value, false, true);\n}\n\nfinal V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {\n Node[] tab; Node p; int n, i;\n // 如果 table 为空,先进行初始化\n if ((tab = table) == null || (n = tab.length) == 0)\n n = (tab = resize()).length;\n \n // 计算索引位置,并找到对应的桶\n if ((p = tab[i = (n - 1) & hash]) == null)\n tab[i] = newNode(hash, key, value, null); // 如果桶为空,直接插入\n else {\n Node e; K k;\n // 检查第一个节点是否匹配\n if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))\n e = p; // 覆盖\n // 如果是树节点,放入树中\n else if (p instanceof TreeNode)\n e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);\n // 如果是链表,遍历插入到尾部\n else {\n for (int binCount = 0; ; ++binCount) {\n if ((e = p.next) == null) {\n p.next = newNode(hash, key, value, null);\n // 如果链表长度达到阈值,转换为红黑树\n if (binCount >= TREEIFY_THRESHOLD - 1)\n treeifyBin(tab, hash);\n break;\n }\n if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))\n break; // 覆盖\n p = e;\n }\n }\n if (e != null) { // 如果找到匹配的 key,则覆盖旧值\n V oldValue = e.value;\n if (!onlyIfAbsent || oldValue == null)\n e.value = value;\n afterNodeAccess(e);\n return oldValue;\n }\n }\n ++modCount; // 修改计数器\n if (++size > threshold)\n resize(); // 检查是否需要扩容\n afterNodeInsertion(evict);\n return null;\n}\n```\n\n\n每次插入新元素后,检查是否需要扩容,如果当前元素个数大于阈值(`capacity * loadFactor`),则进行扩容,扩容后的数组大小是原来的 2 倍;并且重新计算每个节点的索引,进行数据重新分布。\n\n#### [只重写元素的 equals 方法没重写 hashCode,put 的时候会发生什么?](#只重写元素的-equals-方法没重写-hashcode-put-的时候会发生什么)\n\n如果只重写 equals 方法,没有重写 hashCode 方法,那么会导致 equals 相等的两个对象,hashCode 不相等,这样的话,两个对象会被 put 到数组中不同的位置,导致 get 的时候,无法获取到正确的值。"
+ },
+ {
+ "id": 68,
+ "question": "HashMap 怎么查找元素的呢?",
+ "answer": "通过哈希值定位索引 → 定位桶 → 检查第一个节点 → 遍历链表或红黑树查找 → 返回结果。\n\n"
+ },
+ {
+ "id": 69,
+ "question": "HashMap 的 hash 函数是怎么设计的?",
+ "answer": "先拿到 key 的哈希值,是一个 32 位的 int 类型数值,然后再让哈希值的高 16 位和低 16 位进行异或操作,这样能保证哈希分布均匀。\n\n\n```java\nstatic final int hash(Object key) {\n int h;\n // 如果 key 为 null,返回 0;否则,使用 hashCode 并进行扰动\n return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);\n}\n```"
+ },
+ {
+ "id": 70,
+ "question": "为什么 hash 函数能减少哈希冲突?",
+ "answer": "快速回答:哈希表的索引是通过 `h & (n-1)` 计算的,n 是底层数组的容量;n-1 和某个哈希值做 `&` 运算,相当于截取了最低的四位。如果数组的容量很小,只取 h 的低位很容易导致哈希冲突。\n\n通过异或操作将 h 的高位引入低位,可以增加哈希值的随机性,从而减少哈希冲突。\n\n解释一下。\n\n\n\n以初始长度 16 为例,16-1=15。2 进制表示是`0000 0000 0000 0000 0000 0000 0000 1111`。只取最后 4 位相等于哈希值的高位都丢弃了。\n\n\n\n比如说 1111 1111 1111 1111 1111 1111 1111 1111,取最后 4 位,也就是 1111。\n\n1110 1111 1111 1111 1111 1111 1111 1111,取最后 4 位,也是 1111。\n\n不就发生哈希冲突了吗?\n\n这时候 hash 函数 `(h = key.hashCode()) ^ (h >>> 16)` 就派上用场了。\n\n\n\n将哈希值无符号右移 16 位,意味着原哈希值的高 16 位被移到了低 16 位的位置。这样,原始哈希值的高 16 位和低 16 位就可以参与到最终用于索引计算的低位中。\n\n选择 16 位是因为它是 32 位整数的一半,这样处理既考虑了高位的信息,又没有完全忽视低位原本的信息,从而达到了一种微妙的平衡状态。\n\n举个例子(数组长度为 16)。\n\n* 第一个键值对的键:h1 = 0001 0010 0011 0100 0101 0110 0111 1000\n* 第二个键值对的键:h2 = 0001 0010 0011 0101 0101 0110 0111 1000\n\n如果没有 hash 函数,直接取低 4 位,那么 h1 和 h2 的低 4 位都是 1000,也就是说两个键值对都会放在数组的第 8 个位置。\n\n来看一下 hash 函数的处理过程。\n\n①、对于第一个键`h1`的计算:\n\n\n```text\n原始: 0001 0010 0011 0100 0101 0110 0111 1000\n右移: 0000 0000 0000 0000 0001 0010 0011 0100\n异或: ---------------------------------------\n结果: 0001 0010 0011 0100 0100 0100 0100 1100\n```\n\n\n②、对于第二个键`h2`的计算:\n\n\n```text\n原始: 0001 0010 0011 0101 0101 0110 0111 1000\n右移: 0000 0000 0000 0000 0001 0010 0011 0101\n异或: ---------------------------------------\n结果: 0001 0010 0011 0101 0100 0100 0100 1101\n```\n\n\n通过上述计算,我们可以看到`h1`和`h2`经过`h ^ (h >>> 16)`操作后得到了不同的结果。\n\n现在,考虑数组长度为 16 时(需要最低 4 位来确定索引):\n\n* 对于`h1`的最低 4 位是`1100`(十进制中为 12)\n* 对于`h2`的最低 4 位是`1101`(十进制中为 13)\n\n这样,`h1`和`h2`就会被分别放在数组的第 12 个位置和第 13 个位置上,从而避免了哈希冲突。"
+ },
+ {
+ "id": 71,
+ "question": "为什么 HashMap 的容量是 2 的幂次方?",
+ "answer": "是为了快速定位元素在底层数组中的下标。\n\nHashMap 是通过 `hash & (n-1)` 来定位元素下标的,n 为数组的大小,也就是 HashMap 底层数组的容量。\n\n数组长度-1 正好相当于一个“低位掩码”——掩码的低位最好全是 1,这样 & 运算才有意义,否则结果一定是 0。\n\n2 幂次方刚好是偶数,偶数-1 是奇数,奇数的二进制最后一位是 1,也就保证了 `hash &(length-1)` 的最后一位可能为 0,也可能为 1(取决于 hash 的值),这样可以保证哈希值的均匀分布。\n\n换句话说,& 操作的结果就是将哈希值的高位全部归零,只保留低位值。\n\n> a&b 的结果是:a、b 中对应位同时为 1,则结果为 1,否则为 0。例如 5&3=1,5 的二进制是 0101,3 的二进制是 0011,5&3=0001=1。\n\n假设某哈希值的二进制为 `10100101 11000100 00100101`,用它来做 & 运算,我们来看一下结果。\n\n已知 HashMap 的初始长度为 16,16-1=15,二进制是 `00000000 00000000 00001111`(高位用 0 来补齐):\n\n\n```text\n\t 10100101 11000100 00100101\n&\t 00000000 00000000 00001111\n----------------------------------\n\t 00000000 00000000 00000101\n```\n\n\n因为 15 的高位全部是 0,所以 & 运算后的高位结果肯定也是 0,只剩下 4 个低位 `0101`,也就是十进制的 5。\n\n这样,哈希值为 `10100101 11000100 00100101` 的键就会放在数组的第 5 个位置上。\n\n#### [对数组长度取模定位数组下标,这块有没有优化策略?](#对数组长度取模定位数组下标-这块有没有优化策略)\n\n快速回答:HashMap 的策略是将取模运算 `hash % table.length` 优化为位运算 `hash & (length - 1)`。\n\n因为当数组的长度是 2 的 N 次幂时,`hash & (length - 1) = hash % length`。\n\n比如说 9 % 4 = 1,9 的二进制是 1001,4 - 1 = 3,3 的二进制是 0011,9 & 3 = 1001 & 0011 = 0001 = 1。\n\n再比如说 10 % 4 = 2,10 的二进制是 1010,4 - 1 = 3,3 的二进制是 0011,10 & 3 = 1010 & 0011 = 0010 = 2。\n\n当数组的长度不是 2 的 n 次方时,`hash % length` 和 `hash & (length - 1)` 的结果就不一致了。\n\n比如说 7 % 3 = 1,7 的二进制是 0111,3 - 1 = 2,2 的二进制是 0010,7 & 2 = 0111 & 0010 = 0010 = 2。\n\n从二进制角度来看,hash / length = hash / 2n = hash >> n,即把 hash 右移 n 位,此时得到了 hash / 2n 的商。\n\n而被移调的部分,则是 hash % 2n,也就是余数。\n\n2n 的二进制形式为 1,后面跟着 n 个 0,那 2n - 1 的二进制则是 n 个 1。例如 8 = 23,二进制是 1000,7 = 23 - 1,二进制为 0111。\n\n`hash % length`的操作是求 hash 除以 2n 的余数。在二进制中,这个操作的结果就是 hash 的二进制表示中最低 n 位的值。\n\n因为在 2n 取模的操作中,高于 2n 表示位的所有数值对结果没有贡献,只有低于这个阈值的部分才决定余数。\n\n比如说 26 的二进制是 11010,要计算 26 % 8,8 是 23,所以我们关注的是 26 的二进制表示中最低 3 位:11010 的最低 3 位是 010。\n\n010 对应于十进制中的 2,26 % 8 的结果是 2。\n\n当执行`hash & (length - 1)`时,实际上是保留 hash 二进制表示的最低 n 位,其他高位都被清零。\n\n举个例子,hash 为 14,n 为 3,也就是数组长度为 23,也就是 8。\n\n\n```text\n 1110 (hash = 14)\n& 0111 (length - 1 = 7)\n ----\n 0110 (结果 = 6)\n```\n\n\n保留 14 的最低 3 位,高位被清零。\n\n从此,两个运算 `hash % length` 和 `hash & (length - 1)` 有了完美的闭环。在计算机中,位运算的速度要远高于取余运算,因为计算机本质上就是二进制嘛。\n\n#### [说说什么是取模运算?](#说说什么是取模运算)\n\n在 Java 中,通常使用 % 运算符来表示取余,用 `Math.floorMod()` 来表示取模。\n\n当操作数都是正数的话,取模运算和取余运算的结果是一样的;只有操作数出现负数的情况下,结果才会不同。\n\n**取模运算的商向负无穷靠近;取余运算的商向 0 靠近**。这是导致它们两个在处理有负数情况下,结果不同的根本原因。\n\n当数组的长度是 2 的 n 次幂时,取模运算/取余运算可以用位运算来代替,效率更高,毕竟计算机本身只认二进制。\n\n比如说,7 对 3 取余,和 7 对 3 取模,结果都是 1。因为两者都是基于除法运算的,7 / 3 的商是 2,余数是 1。\n\n对于 HashMap 来说,它需要通过 `hash % table.length` 来确定元素在数组中的位置。\n\n比如说,数组长度是 3,hash 是 7,那么 7 % 3 的结果就是 1,也就是此时可以把元素放在下标为 1 的位置。\n\n当 hash 是 8,8 % 3 的结果就是 2,也就是可以把元素放在下标为 2 的位置。\n\n当 hash 是 9,9 % 3 的结果就是 0,也就是可以把元素放在下标为 0 的位置上。\n\n是不是很奇妙,数组的大小为 3,刚好 3 个位置都利用上了。"
+ },
+ {
+ "id": 72,
+ "question": "如果初始化 HashMap,传一个 17 的容量,它会怎么处理?",
+ "answer": "HashMap 会将容量调整到大于等于 17 的最小的 2 的幂次方,也就是 32。\n\n\n\n这是因为哈希表的大小最好是 2 的 N 次幂,这样可以通过 `(n - 1) & hash` 高效计算出索引值。\n\n解释一下。\n\n在 HashMap 的初始化构造方法中,有这样⼀段代码:\n\n\n```java\npublic HashMap(int initialCapacity, float loadFactor) {\n ...\n this.loadFactor = loadFactor;\n this.threshold = tableSizeFor(initialCapacity);\n}\n```\n\n\n阀值 threshold 会通过⽅法 `tableSizeFor()` 进⾏计算。\n\n\n```java\nstatic final int tableSizeFor(int cap) {\n int n = cap - 1;\n n |= n >>> 1;\n n |= n >>> 2;\n n |= n >>> 4;\n n |= n >>> 8;\n n |= n >>> 16;\n return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;\n}\n```\n\n\n①、`int n = cap - 1;` 避免刚好是 2 的幂次方时,容量直接翻倍。\n\n②、接下来通过不断右移(`>>>`)并与自身进行或运算(`|=`),将 n 的二进制表示中的所有低位设置为 1。\n\n* `n |= n >>> 1;` 将最高位的 1 扩展到下一位。\n* `n |= n >>> 2;` 扩展到后两位。\n* 依此类推,直到 `n |= n >>> 16;`,扩展到后十六位,这样从最高位的 1 到最低位,就都变成了 1。\n\n③、如果 n 小于 0,说明 cap 是负数,直接返回 1。\n\n如果 n 大于或等于 MAXIMUM\\_CAPACITY(通常是230),则返回 MAXIMUM\\_CAPACITY。\n\n否则,返回 n + 1,这是因为 n 的所有低位都是 1,所以 n + 1 就是大于 cap 的最小的 2 的幂次方。\n\n#### [初始化 HashMap 的时候需要传入容量吗?](#初始化-hashmap-的时候需要传入容量吗)\n\n如果预先知道 Map 将存储大量键值对,提前指定一个足够大的初始容量可以减少因扩容导致的重哈希操作。\n\n因为每次扩容时,HashMap 需要将现有的元素插入到新的数组中,这个过程相对耗时,尤其是当 Map 中已有大量数据时。\n\n当然了,过大的初始容量会浪费内存,特别是当实际存储的元素远少于初始容量时。如果不指定初始容量,HashMap 将使用默认的初始容量 16。"
+ },
+ {
+ "id": 73,
+ "question": "你还知道哪些哈希函数的构造方法呢?",
+ "answer": "①、**除留取余法**:`H(key)=key%p(p<=N)`,关键字除以一个不大于哈希表长度的正整数 p,所得余数为地址,当然 HashMap 里进行了优化改造,效率更高,散列也更均衡。\n\n除此之外,还有这几种常见的哈希函数构造方法:\n\n②、**直接定址法**:直接根据`key`来映射到对应的数组位置,例如 1232 放到下标 1232 的位置。\n\n③、**数字分析法**:取`key`的某些数字(例如十位和百位)作为映射的位置\n\n④、**平方取中法**:取`key`平方的中间几位作为映射的位置\n\n⑤、将`key`分割成位数相同的几段,然后把它们的叠加和作为映射的位置。\n\n"
+ },
+ {
+ "id": 74,
+ "question": "解决哈希冲突有哪些方法?",
+ "answer": "简版回答:我知道的有 3 种,再哈希法、开放地址法和拉链法。\n\n#### [什么是再哈希法?](#什么是再哈希法)\n\n准备两套哈希算法,当发生哈希冲突的时候,使用另外一种哈希算法,直到找到空槽为止。对哈希算法的设计要求比较高。\n\n#### [什么是开放地址法?](#什么是开放地址法)\n\n遇到哈希冲突的时候,就去寻找下一个空的槽。有 3 种方法:\n\n* 线性探测:从冲突的位置开始,依次往后找,直到找到空槽。\n* 二次探测:从冲突的位置 x 开始,第一次增加 12 个位置,第二次增加 22,直到找到空槽。\n* 双重哈希:和再哈希法类似,准备多个哈希函数,发生冲突的时候,使用另外一个哈希函数。\n\n\n\n#### [什么是拉链法?](#什么是拉链法)\n\n也就是链地址法,当发生哈希冲突的时候,使用链表将冲突的元素串起来。HashMap 采用的正是拉链法。\n\n#### [怎么判断 key 相等呢?](#怎么判断-key-相等呢)\n\n依赖于`key`的`equals()`方法和`hashCode()`方法。\n\n\n```java\nif (e.hash == hash &&\n((k = e.key) == key || (key != null && key.equals(k))))\n```\n\n\n①、**hashCode()** :使用`key`的`hashCode()`方法计算`key`的哈希码。\n\n②、**equals()** :当两个`key`的哈希码相同时,`HashMap`还会调用`key`的`equals()`方法进行精确比较。只有当`equals()`方法返回`true`时,两个`key`才被认为是完全相同的。\n\n如果两个`key`的引用指向了同一个对象,那么它们的`hashCode()`和`equals()`方法都会返回`true`,所以在 equals 判断之前可以先使用`==`运算符判断一次。"
+ },
+ {
+ "id": 75,
+ "question": "为什么 HashMap 链表转红黑树的阈值为 8 呢?",
+ "answer": "树化发生在 table 数组的长度大于 64,且链表的长度大于 8 的时候。\n\n为什么是 8 呢?源码的注释也给出了答案。\n\n\n\n红黑树节点的大小大概是普通节点大小的两倍,所以转红黑树,牺牲了空间换时间,更多的是一种兜底的策略,保证极端情况下的查找效率。\n\n阈值为什么要选 8 呢?和统计学有关。理想情况下,使用随机哈希码,链表里的节点符合泊松分布,出现节点个数的概率是递减的,节点个数为 8 的情况,发生概率仅为`0.00000006`。\n\n至于红黑树转回链表的阈值为什么是 6,而不是 8?是因为如果这个阈值也设置成 8,假如发生碰撞,节点增减刚好在 8 附近,会发生链表和红黑树的不断转换,导致资源浪费。"
+ },
+ {
+ "id": 76,
+ "question": "HashMap扩容发生在什么时候呢?",
+ "answer": "当键值对数量超过阈值,也就是容量 \\* 负载因子时。\n\n\n\n#### [默认的负载因子是多少?](#默认的负载因子是多少)\n\n0.75。\n\n#### [初始容量是多少?](#初始容量是多少)\n\n16。\n\n1 左移 4 位,`0000 0001 → 0001 0000`,也就是 2 的 4 次方。\n\n\n```java\nstatic final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16\n```\n\n\n#### [为什么使用 1 << 4 而不是直接写 16?](#为什么使用-1-4-而不是直接写-16)\n\n写 `1<<4` 主要是为了强调这个值是 2 的幂次方,而不是一个完全随机的选择。\n\n无论 HashMap 是否扩容,其底层的数组长度都应该是 2 的幂次方,因为这样可以通过位运算快速计算出元素的索引。\n\n#### [为什么选择 0.75 作为 HashMap 的默认负载因子呢?](#为什么选择-0-75-作为-hashmap-的默认负载因子呢)\n\n这是一个经验值。如果设置得太低,如 0.5,会浪费空间;如果设置得太高,如 0.9,会增加哈希冲突。\n\n\n\n0.75 是 JDK 作者经过大量验证后得出的最优解,能够最大限度减少 rehash 的次数。"
+ },
+ {
+ "id": 77,
+ "question": "HashMap的扩容机制了解吗?",
+ "answer": "扩容时,HashMap 会创建一个新的数组,其容量是原来的两倍。然后遍历旧哈希表中的元素,将其重新分配到新的哈希表中。\n\n如果当前桶中只有一个元素,那么直接通过键的哈希值与数组大小取模锁定新的索引位置:`e.hash & (newCap - 1)`。\n\n如果当前桶是红黑树,那么会调用 `split()` 方法分裂树节点,以保证树的平衡。\n\n如果当前桶是链表,会通过旧键的哈希值与旧的数组大小取模 `(e.hash & oldCap) == 0` 来作为判断条件,如果条件为真,元素保留在原索引的位置;否则元素移动到原索引 + 旧数组大小的位置。\n\n#### [JDK 7 扩容的时候有什么问题?](#jdk-7-扩容的时候有什么问题)\n\nJDK 7 在扩容的时候使用头插法来重新插入链表节点,这样会导致链表无法保持原有的顺序。\n\n详细解释一下。\n\nJDK 7 是通过哈希值与数组大小-1 进行与运算确定元素下标的。\n\n\n```java\nstatic int indexFor(int h, int length) {\n return h & (length-1);\n}\n```\n\n\n我们来假设:\n\n* 数组 table 的长度为 2\n* 键的哈希值为 3、7、5\n\n取模运算后,键发生了哈希冲突,它们都需要放到 `table[1]` 的桶上。那么扩容前就是这个样子:\n\n\n\n假设负载因子 loadFactor 为 1,也就是当元素的个数大于 table 的长度时进行扩容。\n\n扩容后的数组容量为 4。\n\n* key 3 取模(3%4)后是 3,放在 `table[3]` 上。\n* key 7 取模(7%4)后是 3,放在 `table[3]` 上的链表头部。\n* key 5 取模(5%4)后是 1,放在 `table[1]` 上。\n\n\n\n可以看到,由于 JDK 采用的是头插法,7 跑到 3 的前面了,原来的顺序是 3、7、5,7 在 3 的后面。\n\n\n```java\nfor (Entry e : oldTable) {\n while (null != e) {\n Entry next = e.next;\n int i = indexFor(e.hash, newCapacity);\n e.next = newTable[i];\n newTable[i] = e;\n e = next;\n }\n}\n```\n\n\n最好的情况就是,扩容后的 7 还在 3 的后面,保持原来的顺序。\n\n#### [JDK 8 是怎么解决这个问题的?](#jdk-8-是怎么解决这个问题的)\n\nJDK 8 改用了尾插法,并且当 `(e.hash & oldCap) == 0` 时,元素保留在原索引的位置;否则元素移动到原索引 + 旧数组大小的位置。\n\n\n```java\nNode loHead = null, loTail = null;\nNode hiHead = null, hiTail = null;\nNode next;\ndo {\n next = e.next;\n if ((e.hash & oldCap) == 0) {\n if (loTail == null)\n loHead = e;\n else\n loTail.next = e;\n loTail = e;\n }\n else {\n if (hiTail == null)\n hiHead = e;\n else\n hiTail.next = e;\n hiTail = e;\n }\n} while ((e = next) != null);\nif (loHead != null)\n newTab[j] = loHead;\nif (hiHead != null)\n newTab[j + oldCap] = hiHead;\n```\n\n\n由于扩容时,数组长度会翻倍,例如:16 → 32, 因此,新数组的索引范围是原索引范围的两倍。\n\n原索引 `index = (n - 1) & hash`,扩容后的新索引就是 `index = (2n - 1) & hash`。\n\n也就是说,如果 `(e.hash & oldCap) == 0`,元素在新数组中的位置与旧位置相同;否则,元素在新数组中的位置是旧位置 + 旧数组大小。\n\n假设扩容前的数组长度为 16(n-1 也就是二进制的 0000 1111,1X20+1X21+1X22+1X23=1+2+4+8=15),key1 为 5(二进制为 0000 0101),key2 为 21(二进制为 0001 0101)。\n\n* key1 和 n-1 做 & 运算后为 0000 0101,也就是 5;\n* key2 和 n-1 做 & 运算后为 0000 0101,也就是 5。\n* 此时哈希冲突了,用拉链法来解决哈希冲突。\n\n现在,HashMap 进行了扩容,容量为原来的 2 倍,也就是 32(n-1 也就是二进制的 0001 1111,1X20+1X21+1X22+1X23+1X24=1+2+4+8+16=31)。\n\n* key1 和 n-1 做 & 运算后为 0000 0101,也就是 5;\n* key2 和 n-1 做 & 运算后为 0001 0101,也就是 21=5+16,就是数组扩容前的位置+原数组的长度。\n\n\n\n这样可以避免重新计算所有元素的哈希值,只需检查高位的某一位,就可以快速确定新位置。\n\n\n\n#### [扩容的时候每个节点都要进行位运算吗?](#扩容的时候每个节点都要进行位运算吗)\n\n不需要。HashMap 会通过 `(e.hash & oldCap)` 来判断节点是否需要移动,0 的话保留原索引;1 才需要移动到新索引(原索引 + oldCap)。\n\n这样就避免了 hashCode 的重新计算,大大提升了扩容的性能。\n\n所以,哪怕有几十万条数据,可能只有一半的数据才需要移动到新位置。另外,位运算的计算速度非常快,因此,尽管扩容操作涉及到遍历整个哈希表并对每个节点进行判断,但这部分操作的计算成本是相对较低的。"
+ },
+ {
+ "id": 78,
+ "question": "JDK 8 对 HashMap 做了哪些优化呢?",
+ "answer": "①、底层数据结构由数组 + 链表改成了数组 + 链表或红黑树的结构。\n\n如果多个键映射到了同一个哈希值,链表会变得很长,在最坏的情况下,当所有的键都映射到同一个桶中时,性能会退化到 O(n),而红黑树的时间复杂度是 O(logn)。\n\n②、链表的插入方式由头插法改为了尾插法。头插法在扩容后容易改变原来链表的顺序。\n\n③、扩容的时机由插入时判断改为插入后判断,这样可以避免在每次插入时都进行不必要的扩容检查,因为有可能插入后仍然不需要扩容。\n\n\n\n④、哈希扰动算法也进行了优化。JDK 7 是通过多次移位和异或运算来实现的。\n\n\n\nJDK 8 让 hash 值的高 16 位和低 16 位进行了异或运算,让高位的信息也能参与到低位的计算中,这样可以极大程度上减少哈希碰撞。\n\n"
+ },
+ {
+ "id": 79,
+ "question": "你能自己设计实现一个 HashMap 吗?",
+ "answer": "> 这道题**快手**常考。红黑树版咱们多半是写不出来的,但是数组+链表版还是问题不大,详细可见: [手写 HashMap,快手面试官直呼内行!](https://mp.weixin.qq.com/s/Z9yoRZW5itrtgbS-cj0bUg)。\n\n可以,我先说一下整体的设计思路:\n\n* 第一步,实现一个 hash 函数,对键的 hashCode 进行扰动\n* 第二步,实现一个拉链法的方法来解决哈希冲突\n* 第三步,扩容后,重新计算哈希值,将元素放到新的数组中\n\n\n\n完整代码:\n\n"
+ },
+ {
+ "id": 80,
+ "question": "HashMap 是线程安全的吗?",
+ "answer": "HashMap 不是线程安全的,主要有以下几个问题:\n\n①、多线程下扩容会死循环。JDK7 中的 HashMap 使用的是头插法来处理链表,在多线程环境下扩容会出现环形链表,造成死循环。\n\n\n\n不过,JDK 8 时通过尾插法修复了这个问题,扩容时会保持链表原来的顺序。\n\n②、多线程在进行 put 元素的时候,可能会导致元素丢失。因为计算出来的位置可能会被其他线程覆盖掉,比如说一个县城 put 3 的时候,另外一个线程 put 了 7,就把 3 给弄丢了。\n\n\n\n③、put 和 get 并发时,可能导致 get 为 null。线程 1 执行 put 时,因为元素个数超出阈值而扩容,线程 2 此时执行 get,就有可能出现这个问题。\n\n\n\n因为线程 1 执行完 table = newTab 之后,线程 2 中的 table 已经发生了改变,比如说索引 3 的键值对移动到了索引 7 的位置,此时线程 2 去 get 索引 3 的元素就 get 不到了。"
+ },
+ {
+ "id": 81,
+ "question": "怎么解决 HashMap 线程不安全的问题呢?",
+ "answer": "在早期的 JDK 版本中,可以用 Hashtable 来保证线程安全。Hashtable 在方法上加了 。\n\n\n\n另外,可以通过 `Collections.synchronizedMap` 方法返回一个线程安全的 Map,内部是通过 synchronized 对象锁来保证线程安全的,比在方法上直接加 synchronized 关键字更轻量级。\n\n\n\n更优雅的解决方案是使用并发工具包下的 ,使用了+ 来保证线程安全。\n\n"
+ },
+ {
+ "id": 82,
+ "question": "HashMap 内部节点是有序的吗?",
+ "answer": "无序的,根据 hash 值随机插入。"
+ },
+ {
+ "id": 83,
+ "question": "讲讲 LinkedHashMap 怎么实现有序的?",
+ "answer": "LinkedHashMap 在 HashMap 的基础上维护了一个双向链表,通过 before 和 after 标识前置节点和后置节点。\n\n\n\n从而实现插入的顺序或访问顺序。\n\n"
+ },
+ {
+ "id": 84,
+ "question": "讲讲 TreeMap 怎么实现有序的?",
+ "answer": "TreeMap 通过 key 的比较器来决定元素的顺序,如果没有指定比较器,那么 key 必须实现 。\n\n\n\nTreeMap 的底层是红黑树,红黑树是一种自平衡的二叉查找树,每个节点都大于其左子树中的任何节点,小于其右子节点树种的任何节点。\n\n\n\n插入或者删除元素时通过旋转和染色来保持树的平衡。\n\n查找的时候从根节点开始,利用二叉查找树的特点,逐步向左子树或者右子树递归查找,直到找到目标元素。"
+ },
+ {
+ "id": 85,
+ "question": "TreeMap 和 HashMap 的区别",
+ "answer": "①、HashMap 是基于数组+链表+红黑树实现的,put 元素的时候会先计算 key 的哈希值,然后通过哈希值计算出元素在数组中的存放下标,然后将元素插入到指定的位置,如果发生哈希冲突,会使用链表来解决,如果链表长度大于 8,会转换为红黑树。\n\n②、TreeMap 是基于红黑树实现的,put 元素的时候会先判断根节点是否为空,如果为空,直接插入到根节点,如果不为空,会通过 key 的比较器来判断元素应该插入到左子树还是右子树。\n\n在没有发生哈希冲突的情况下,HashMap 的查找效率是 `O(1)`。适用于查找操作比较频繁的场景。\n\nTreeMap 的查找效率是 `O(logn)`。并且保证了元素的顺序,因此适用于需要大量范围查找或者有序遍历的场景。"
+ }
+ ]
+ },
+ {
+ "id": 18,
+ "categoryName": "Set",
+ "questions": [
+ {
+ "id": 86,
+ "question": "讲讲 HashSet 的底层实现?",
+ "answer": "HashSet 是由 HashMap 实现的,只不过值由一个固定的 Object 对象填充,而键用于操作。\n\n\n```java\npublic class HashSet\n extends AbstractSet\n implements Set, Cloneable, java.io.Serializable\n{\n static final long serialVersionUID = -5024744406713321676L;\n private transient HashMap map;\n // Dummy value to associate with an Object in the backing Map\n private static final Object PRESENT = new Object();\n // ……\n}\n```\n\n\n实际开发中,HashSet 并不常用,比如,如果我们需要按照顺序存储一组元素,那么 ArrayList 和 LinkedList 更适合;如果我们需要存储键值对并根据键进行查找,那么 HashMap 可能更适合。\n\nHashSet 主要用于去重,比如,我们需要统计一篇文章中有多少个不重复的单词,就可以使用 HashSet 来实现。\n\n\n```java\n// 创建一个 HashSet 对象\nHashSet set = new HashSet<>();\n\n// 添加元素\nset.add(\"practice-mate\");\nset.add(\"练习伴侣二\");\nset.add(\"陈清扬\");\nset.add(\"practice-mate\");\n\n// 输出 HashSet 的元素个数\nSystem.out.println(\"HashSet size: \" + set.size()); // output: 3\n\n// 遍历 HashSet\nfor (String s : set) {\n System.out.println(s);\n}\n```\n\n\nHashSet 会自动去重,因为它是用 HashMap 实现的,HashMap 的键是唯一的,相同键会覆盖掉原来的键,于是第二次 add 一个相同键的元素会直接覆盖掉第一次的键。\n\n\n\n#### [HashSet 和 ArrayList 的区别](#hashset-和-arraylist-的区别)\n\n* ArrayList 是基于动态数组实现的,HashSet 是基于 HashMap 实现的。\n* ArrayList 允许重复元素和 null 值,可以有多个相同的元素;HashSet 保证每个元素唯一,不允许重复元素,基于元素的 hashCode 和 equals 方法来确定元素的唯一性。\n* ArrayList 保持元素的插入顺序,可以通过索引访问元素;HashSet 不保证元素的顺序,元素的存储顺序依赖于哈希算法,并且可能随着元素的添加或删除而改变。\n\n#### [HashSet 怎么判断元素重复,重复了是否 put](#hashset-怎么判断元素重复-重复了是否-put)\n\nHashSet 的 add 方法是通过调用 HashMap 的 put 方法实现的:\n\n\n```java\npublic boolean add(E e) {\n return map.put(e, PRESENT)==null;\n}\n```\n\n\n所以 HashSet 判断元素重复的逻辑底层依然是 HashMap 的底层逻辑:\n\n\n\nHashMap 在插入元素时,通常需要三步:\n\n第一步,通过 hash 方法计算 key 的哈希值。\n\n\n```java\nstatic final int hash(Object key) {\n int h;\n return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);\n}\n```\n\n\n第二步,数组进行第一次扩容。\n\n\n```java\nif ((tab = table) == null || (n = tab.length) == 0)\n n = (tab = resize()).length;\n```\n\n\n第三步,根据哈希值计算 key 在数组中的下标,如果对应下标正好没有存放数据,则直接插入。\n\n\n```java\nif ((p = tab[i = (n - 1) & hash]) == null)\n tab[i] = newNode(hash, key, value, null);\n```\n\n\n如果对应下标已经有数据了,就需要判断是否为相同的 key,是则覆盖 value,否则需要判断是否为树节点,是则向树中插入节点,否则向链表中插入数据。\n\n\n```java\nelse {\n Node e; K k;\n if (p.hash == hash &&\n ((k = p.key) == key || (key != null && key.equals(k))))\n e = p;\n else if (p instanceof TreeNode)\n e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);\n else {\n for (int binCount = 0; ; ++binCount) {\n if ((e = p.next) == null) {\n p.next = newNode(hash, key, value, null);\n if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st\n treeifyBin(tab, hash);\n break;\n }\n if (e.hash == hash &&\n ((k = e.key) == key || (key != null && key.equals(k))))\n break;\n p = e;\n }\n }\n}\n```\n\n\n也就是说,HashSet 通过元素的哈希值来判断元素是否重复,如果重复了,会覆盖原来的值。\n\n\n```java\nif (e != null) { // existing mapping for key\n V oldValue = e.value;\n if (!onlyIfAbsent || oldValue == null)\n e.value = value;\n afterNodeAccess(e);\n return oldValue;\n}\n```"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": 3,
+ "topicName": "Java并发",
+ "categories": [
+ {
+ "id": 19,
+ "categoryName": "基础",
+ "questions": [
+ {
+ "id": 87,
+ "question": "并行跟并发有什么区别?",
+ "answer": "* 并行是多核 CPU 上的多任务处理,多个任务在同一时间真正地同时执行。\n* 并发是单核 CPU 上的多任务处理,多个任务在同一时间段内交替执行,通过时间片轮转实现交替执行,用于解决 IO 密集型任务的瓶颈。\n\n\n\n举个例子,就好像我们去食堂打饭,并行就是每个人对应一个阿姨,同时打饭;而并发就是一个阿姨,轮流给每个人打饭,假如有个人磨磨唧唧,阿姨就会吆喝下一个人,这样就能提高食堂的打饭效率。\n\n\n\n#### [你是如何理解线程安全的?](#你是如何理解线程安全的)\n\n如果一段代码块或者一个方法被多个线程同时执行,还能够正确地处理共享数据,那么这段代码块或者这个方法就是线程安全的。\n\n可以从三个要素来确保线程安全:\n\n**①、原子性**:一个操作要么完全执行,要么完全不执行,不会出现中间状态。\n\n\n\n可以通过同步关键字 synchronized 或原子操作,如 AtomicInteger 来保证原子性。\n\n\n```java\nAtomicInteger count = new AtomicInteger(0);\ncount.incrementAndGet(); // 原子操作\n```\n\n\n**②、可见性**:当一个线程修改了共享变量,其他线程能够立即看到变化。\n\n\n\n可以通过 volatile 关键字来保证可见性。\n\n\n```java\nprivate volatile String itwanger = \"练习伴侣二\";\n```\n\n\n**③、有序性**:要确保线程不会因为死锁、饥饿、活锁等问题导致无法继续执行。\n\n"
+ },
+ {
+ "id": 88,
+ "question": "说说进程和线程的区别?",
+ "answer": "进程说简单点就是我们在电脑上启动的一个个应用。它是操作系统分配资源的最小单位。\n\n线程是进程中的独立执行单元。多个线程可以共享同一个进程的资源,如内存;每个线程都有自己独立的栈和寄存器。\n\n\n\n#### [如何理解协程?](#如何理解协程)\n\n协程被视为比线程更轻量级的并发单元,可以在单线程中实现并发执行,由我们开发者显式调度。\n\n协程是在用户态进行调度的,避免了线程切换时的内核态开销。\n\nJava 自身是不支持携程的,我们可以使用 Quasar、Kotlin 等框架来实现协程。\n\n\n```java\nfun main() = runBlocking {\n launch {\n delay(1000L)\n println(\"World!\")\n }\n println(\"Hello,\")\n}\n```\n\n\n#### [线程间是如何进行通信的?](#线程间是如何进行通信的)\n\n原则上可以通过消息传递和共享内存两种方法来实现。Java 采用的是共享内存的并发模型。\n\n这个模型被称为 Java 内存模型,简写为 JMM,它决定了一个线程对共享变量的写入,何时对另外一个线程可见。当然了,本地内存是 JMM 的一个抽象概念,并不真实存在。\n\n用一句话来概括就是:共享变量存储在主内存中,每个线程的私有本地内存,存储的是这个共享变量的副本。\n\n\n\n线程 A 与线程 B 之间如要通信,需要要经历 2 个步骤:\n\n* 线程 A 把本地内存 A 中的共享变量副本刷新到主内存中。\n* 线程 B 到主内存中读取线程 A 刷新过的共享变量,再同步到自己的共享变量副本中。\n\n"
+ },
+ {
+ "id": 89,
+ "question": "说说线程有几种创建方式?",
+ "answer": "有三种,分别是继承 Thread 类、实现 Runnable 接口、实现 Callable 接口。\n\n\n\n第一种需要重写父类 Thread 的 `run()` 方法,并且调用 `start()` 方法启动线程。\n\n\n```java\nclass ThreadTask extends Thread {\n public void run() {\n System.out.println(\"看完练习伴侣,上岸了!\");\n }\n\n public static void main(String[] args) {\n ThreadTask task = new ThreadTask();\n task.start();\n }\n}\n```\n\n\n这种方法的缺点是,如果 ThreadTask 已经继承了另外一个类,就不能再继承 Thread 类了,因为 Java 不支持多重继承。\n\n第二种需要重写 Runnable 接口的 `run()` 方法,并将实现类的对象作为参数传递给 Thread 对象的构造方法,最后调用 `start()` 方法启动线程。\n\n\n```java\nclass RunnableTask implements Runnable {\n public void run() {\n System.out.println(\"看完练习伴侣,上岸了!\");\n }\n\n public static void main(String[] args) {\n RunnableTask task = new RunnableTask();\n Thread thread = new Thread(task);\n thread.start();\n }\n}\n```\n\n\n这种方法的优点是可以避免 Java 的单继承限制,并且更符合面向对象的编程思想,因为 Runnable 接口将任务代码和线程控制的代码解耦了。\n\n第三种需要重写 Callable 接口的 `call()` 方法,然后创建 FutureTask 对象,参数为 Callable 实现类的对象;紧接着创建 Thread 对象,参数为 FutureTask 对象,最后调用 `start()` 方法启动线程。\n\n\n```java\nclass CallableTask implements Callable {\n public String call() {\n return \"看完练习伴侣,上岸了!\";\n }\n\n public static void main(String[] args) throws ExecutionException, InterruptedException {\n CallableTask task = new CallableTask();\n FutureTask futureTask = new FutureTask<>(task);\n Thread thread = new Thread(futureTask);\n thread.start();\n System.out.println(futureTask.get());\n }\n}\n```\n\n\n这种方法的优点是可以获取线程的执行结果。\n\n#### [一个 8G 内存的系统最多能创建多少个线程?](#一个-8g-内存的系统最多能创建多少个线程)\n\n理论上大约 8000 个。\n\n创建线程的时候,至少需要分配一个虚拟机栈,在 64 位操作系统中,默认大小为 1M,因此一个线程大约需要 1M 的内存。\n\n但 JVM、操作系统本身的运行就要占一定的内存空间,所以实际上可以创建的线程数远比 8000 少。\n\n详细解释一下。\n\n可以通过 `java -XX:+PrintFlagsFinal -version | grep ThreadStackSize` 命令查看 JVM 栈的默认大小。\n\n\n\n其中 ThreadStackSize 的单位是 KB,也就是说默认的 JVM 栈大小是 1024 KB,也就是 1M。\n\n#### [启动一个 Java 程序,你能说说里面有哪些线程吗?](#启动一个-java-程序-你能说说里面有哪些线程吗)\n\n首先是 main 线程,这是程序执行的入口。\n\n然后是垃圾回收线程,它是一个后台线程,负责回收不再使用的对象。\n\n还有编译器线程,比如 JIT,负责把一部分热点代码编译后放到 codeCache 中。\n\n\n\n可以通过下面的代码进行检测:\n\n\n```java\nclass ThreadLister {\n public static void main(String[] args) {\n // 获取所有线程的堆栈跟踪\n Map threads = Thread.getAllStackTraces();\n for (Thread thread : threads.keySet()) {\n System.out.println(\"Thread: \" + thread.getName() + \" (ID=\" + thread.getId() + \")\");\n }\n }\n}\n```\n\n\n结果如下所示:\n\n\n```text\nThread: Monitor Ctrl-Break (ID=5)\nThread: Reference Handler (ID=2)\nThread: main (ID=1)\nThread: Signal Dispatcher (ID=4)\nThread: Finalizer (ID=3)\n```\n\n\n简单解释下:\n\n* `Thread: main (ID=1)` - 主线程,Java 程序启动时由 JVM 创建。\n* `Thread: Reference Handler (ID=2)` - 这个线程是用来处理引用对象的,如软引用、弱引用和虚引用。负责清理被 JVM 回收的对象。\n* `Thread: Finalizer (ID=3)` - 终结器线程,负责调用对象的 finalize 方法。对象在垃圾回收器标记为可回收之前,由该线程执行其 finalize 方法,用于执行特定的资源释放操作。\n* `Thread: Signal Dispatcher (ID=4)` - 信号调度线程,处理来自操作系统的信号,将它们转发给 JVM 进行进一步处理,例如响应中断、停止等信号。\n* `Thread: Monitor Ctrl-Break (ID=5)` - 监视器线程,通常由一些特定的 IDE 创建,用于在开发过程中监控和管理程序执行或者处理中断。"
+ },
+ {
+ "id": 90,
+ "question": "调用 start 方法时会执行 run 方法,那怎么不直接调用 run方法?",
+ "answer": "调用 `start()` 会创建一个新的线程,并异步执行 `run()` 方法中的代码。\n\n直接调用 `run()` 方法只是一个普通的同步方法调用,所有代码都在当前线程中执行,不会创建新线程。没有新的线程创建,也就达不到多线程并发的目的。\n\n通过敲代码体验一下。\n\n\n```java\nclass MyThread extends Thread {\n public void run() {\n System.out.println(Thread.currentThread().getName());\n }\n\n public static void main(String[] args) {\n MyThread t1 = new MyThread();\n t1.start(); // 正确的方式,创建一个新线程,并在新线程中执行 run()\n t1.run(); // 仅在主线程中执行 run(),没有创建新线程\n }\n}\n```\n\n\n来看输出结果:\n\n\n```text\nmain\nThread-0\n```\n\n\n也就是说,调用 `start()` 方法会通知 JVM,去调用底层的线程调度机制来启动新线程。\n\n\n\n调用 `start()` 后,线程进入就绪状态,等待操作系统调度;一旦调度执行,线程会执行其 `run()` 方法中的代码。"
+ },
+ {
+ "id": 91,
+ "question": "线程有哪些常用的调度方法?",
+ "answer": "比如说 start 方法用于启动线程并让操作系统调度执行;sleep 方法用于让当前线程休眠一段时间;wait 方法会让当前线程等待,notify 会唤醒一个等待的线程。\n\n\n\n#### [说说wait方法和notify方法?](#说说wait方法和notify方法)\n\n当线程 A 调用共享对象的 `wait()` 方法时,线程 A 会被阻塞挂起,直到:\n\n* 线程 B 调用了共享对象的 `notify()` 方法或者 `notifyAll()` 方法;\n* 其他线程调用线程 A 的 `interrupt()` 方法,导致线程 A 抛出 InterruptedException 异常。\n\n线程 A 调用共享对象的 `wait(timeout)`方法后,没有在指定的 timeout 时间内被其它线程唤醒,那么这个方法会因为超时而返回。\n\n当线程 A 调用共享对象的 `notify()` 方法后,会唤醒一个在这个共享对象上调用 wait 系列方法被挂起的线程。\n\n共享对象上可能会有多个线程在等待,具体唤醒哪个线程是随机的。\n\n如果调用的是 notifyAll 方法,会唤醒所有在这个共享变量上调用 wait 系列方法而被挂起的线程。\n\n#### [说说 sleep 方法?](#说说-sleep-方法)\n\n当线程 A 调用了 Thread 的 sleep 方法后,线程 A 会暂时让出指定时间的执行权。\n\n指定的睡眠时间到了后该方法会正常返回,接着参与 CPU 调度,获取到 CPU 资源后可以继续执行。\n\n#### [说说yield方法?](#说说yield方法)\n\n`yield()` 方法的目的是让当前线程让出 CPU 使用权,回到就绪状态。但是线程调度器可能会忽略。\n\n#### [说说interrupt方法?](#说说interrupt方法)\n\n`interrupt()` 方法用于通知线程停止,但不会直接终止线程,需要线程自行处理中断标志。\n\n常与 `isInterrupted()` 或 `Thread.interrupted()` 配合使用。\n\n\n```java\nThread thread = new Thread(() -> {\n while (!Thread.currentThread().isInterrupted()) {\n System.out.println(\"Running\");\n }\n System.out.println(\"Interrupted\");\n});\nthread.start();\nthread.interrupt(); // 中断线程\n```\n\n\n#### [说说 stop 方法?](#说说-stop-方法)\n\nstop 方法用来强制停止线程,目前已经处于废弃状态,因为 stop 方法可能会在不一致的状态下释放锁,破坏对象的一致性。\n\n"
+ },
+ {
+ "id": 92,
+ "question": "线程有几种状态?",
+ "answer": "6 种。\n\nnew 代表线程被创建但未启动;runnable 代表线程处于就绪或正在运行状态,由操作系统调度;blocked 代表线程被阻塞,等待获取锁;waiting 代表线程等待其他线程的通知或中断;timed\\_waiting 代表线程会等待一段时间,超时后自动恢复;terminated 代表线程执行完毕,生命周期结束。\n\n\n\n也就是说,线程的生命周期可以分为五个主要阶段:新建、就绪、运行、阻塞和终止。线程在运行过程中会根据状态的变化在这些阶段之间切换。\n\n\n```java\nclass ThreadStateExample {\n public static void main(String[] args) throws InterruptedException {\n Thread thread = new Thread(() -> {\n try {\n Thread.sleep(2000); // TIMED_WAITING\n synchronized (ThreadStateExample.class) {\n ThreadStateExample.class.wait(); // WAITING\n }\n } catch (InterruptedException e) {\n Thread.currentThread().interrupt();\n }\n });\n\n System.out.println(\"State after creation: \" + thread.getState()); // NEW\n\n thread.start();\n System.out.println(\"State after start: \" + thread.getState()); // RUNNABLE\n\n Thread.sleep(500);\n System.out.println(\"State while sleeping: \" + thread.getState()); // TIMED_WAITING\n\n synchronized (ThreadStateExample.class) {\n ThreadStateExample.class.notify(); // 唤醒线程\n }\n\n thread.join();\n System.out.println(\"State after termination: \" + thread.getState()); // TERMINATED\n }\n}\n```\n\n\n用一个表格来做个总结:\n\n| 状态 | 说明 |\n| --- | --- |\n| NEW | 当线程被创建后,如通过`new Thread()`,它处于新建状态。此时,线程已经被分配了必要的资源,但还没有开始执行。 |\n| RUNNABLE | 当调用线程的`start()`方法后,线程进入可运行状态。在这个状态下,线程可能正在运行也可能正在等待获取 CPU 时间片,具体取决于线程调度器的调度策略。 |\n| BLOCKED | 线程在试图获取一个锁以进入同步块/方法时,如果锁被其他线程持有,线程将进入阻塞状态,直到它获取到锁。 |\n| WAITING | 线程进入等待状态是因为调用了如下方法之一:`Object.wait()`或`LockSupport.park()`。在等待状态下,线程需要其他线程显式地唤醒,否则不会自动执行。 |\n| TIME\\_WAITING | 当线程调用带有超时参数的方法时,如`Thread.sleep(long millis)`、`Object.wait(long timeout)` 或`LockSupport.parkNanos()`,它将进入超时等待状态。线程在指定的等待时间过后会自动返回可运行状态。 |\n| TERMINATED | 当线程的`run()`方法执行完毕后,或者因为一个未捕获的异常终止了执行,线程进入终止状态。一旦线程终止,它的生命周期结束,不能再被重新启动。 |\n\n#### [如何强制终止线程?](#如何强制终止线程)\n\n第一步,调用线程的 `interrupt()` 方法,请求终止线程。\n\n第二步,在线程的 `run()` 方法中检查中断状态,如果线程被中断,就退出线程。\n\n\n```java\nclass MyTask implements Runnable {\n @Override\n public void run() {\n while (!Thread.currentThread().isInterrupted()) {\n try {\n System.out.println(\"Running...\");\n Thread.sleep(1000); // 模拟工作\n } catch (InterruptedException e) {\n // 捕获中断异常后,重置中断状态\n Thread.currentThread().interrupt();\n System.out.println(\"Thread interrupted, exiting...\");\n break;\n }\n }\n }\n}\n\npublic class Main {\n public static void main(String[] args) throws InterruptedException {\n Thread thread = new Thread(new MyTask());\n thread.start();\n Thread.sleep(3000); // 主线程等待3秒\n thread.interrupt(); // 请求终止线程\n }\n}\n```\n\n\n中断结果:\n\n"
+ },
+ {
+ "id": 93,
+ "question": "什么是线程上下文切换?",
+ "answer": "线程上下文切换是指 CPU 从一个线程切换到另一个线程执行时的过程。\n\n在线程切换的过程中,CPU 需要保存当前线程的执行状态,并加载下一个线程的上下文。\n\n之所以要这样,是因为 CPU 在同一时刻只能执行一个线程,为了实现多线程并发执行,需要不断地在多个线程之间切换。\n\n\n\n为了让用户感觉多个线程是在同时执行的, CPU 资源的分配采用了时间片轮转的方式,线程在时间片内占用 CPU 执行任务。当线程使用完时间片后,就会让出 CPU 让其他线程占用。\n\n\n\n#### [线程可以被多核调度吗?](#线程可以被多核调度吗)\n\n多核处理器提供了并行执行多个线程的能力。每个核心可以独立执行一个或多个线程,操作系统的任务调度器会根据策略和算法,如优先级调度、轮转调度等,决定哪个线程何时在哪个核心上运行。"
+ },
+ {
+ "id": 94,
+ "question": "守护线程了解吗?",
+ "answer": "了解,守护线程是一种特殊的线程,它的作用是为其他线程提供服务。\n\nJava 中的线程分为两类,一种是守护线程,另外一种是用户线程。\n\nJVM 启动时会调用 main 方法,main 方法所在的线程就是一个用户线程。在 JVM 内部,同时还启动了很多守护线程,比如垃圾回收线程。\n\n#### [守护线程和用户线程有什么区别呢?](#守护线程和用户线程有什么区别呢)\n\n区别之一是当最后一个非守护线程束时, JVM 会正常退出,不管当前是否存在守护线程,也就是说守护线程是否结束并不影响 JVM 退出。\n\n换而言之,只要有一个用户线程还没结束,正常情况下 JVM 就不会退出。"
+ },
+ {
+ "id": 95,
+ "question": "线程间有哪些通信方式?",
+ "answer": "线程之间传递信息的方式有多种,比如说使用 volatile 和 synchronized 关键字共享对象、使用 `wait()` 和 `notify()` 方法实现生产者-消费者模式、使用 Exchanger 进行数据交换、使用 Condition 实现线程间的协调等。\n\n#### [简单说说 volatile 和 synchronized 的使用方式?](#简单说说-volatile-和-synchronized-的使用方式)\n\n多个线程可以通过 volatile 和 synchronized 关键字访问和修改同一个对象,从而实现信息的传递。\n\n可以用来修饰成员变量,告知程序任何对该变量的访问均需要从共享内存中获取,并同步刷新回共享内存,保证所有线程对变量访问的可见性。\n\n可以修饰方法,或者同步代码块,确保多个线程在同一个时刻只有一个线程在执行方法或代码块。\n\n\n```java\nclass SharedObject {\n private String message;\n private boolean hasMessage = false;\n\n public synchronized void writeMessage(String message) {\n while (hasMessage) {\n try {\n wait();\n } catch (InterruptedException e) {\n Thread.currentThread().interrupt();\n }\n }\n this.message = message;\n hasMessage = true;\n notifyAll();\n }\n\n public synchronized String readMessage() {\n while (!hasMessage) {\n try {\n wait();\n } catch (InterruptedException e) {\n Thread.currentThread().interrupt();\n }\n }\n hasMessage = false;\n notifyAll();\n return message;\n }\n}\n\npublic class Main {\n public static void main(String[] args) {\n SharedObject sharedObject = new SharedObject();\n\n Thread writer = new Thread(() -> {\n sharedObject.writeMessage(\"Hello from Writer!\");\n });\n\n Thread reader = new Thread(() -> {\n String message = sharedObject.readMessage();\n System.out.println(\"Reader received: \" + message);\n });\n\n writer.start();\n reader.start();\n }\n}\n```\n\n\n#### [wait() 和 notify() 方法的使用方式了解吗?](#wait-和-notify-方法的使用方式了解吗)\n\n一个线程调用共享对象的 `wait()` 方法时,它会进入该对象的等待池,释放已经持有的锁,进入等待状态。\n\n一个线程调用 `notify()` 方法时,它会唤醒在该对象等待池中等待的一个线程,使其进入锁池,等待获取锁。\n\n\n```java\nclass MessageBox {\n private String message;\n private boolean empty = true;\n\n public synchronized void produce(String message) {\n while (!empty) {\n try {\n wait();\n } catch (InterruptedException e) {\n Thread.currentThread().interrupt();\n }\n }\n empty = false;\n this.message = message;\n notifyAll();\n }\n\n public synchronized String consume() {\n while (empty) {\n try {\n wait();\n } catch (InterruptedException e) {\n Thread.currentThread().interrupt();\n }\n }\n empty = true;\n notifyAll();\n return message;\n }\n}\n\npublic class Main {\n public static void main(String[] args) {\n MessageBox box = new MessageBox();\n\n Thread producer = new Thread(() -> {\n box.produce(\"Message from producer\");\n });\n\n Thread consumer = new Thread(() -> {\n String message = box.consume();\n System.out.println(\"Consumer received: \" + message);\n });\n\n producer.start();\n consumer.start();\n }\n}\n```\n\n\n也提供了类似的方法,`await()` 负责阻塞、`signal()` 和 `signalAll()` 负责通知。\n\n通常与锁 一起使用,为线程提供了一种等待某个条件成真的机制,并允许其他线程在该条件变化时通知等待线程。\n\n#### [Exchanger 的使用方式了解吗?](#exchanger-的使用方式了解吗)\n\nExchanger 是一个同步点,可以在两个线程之间交换数据。一个线程调用 `exchange()` 方法,将数据传递给另一个线程,同时接收另一个线程的数据。\n\n\n```java\nclass Main {\n public static void main(String[] args) {\n Exchanger exchanger = new Exchanger<>();\n\n Thread thread1 = new Thread(() -> {\n try {\n String message = \"Message from thread1\";\n String response = exchanger.exchange(message);\n System.out.println(\"Thread1 received: \" + response);\n } catch (InterruptedException e) {\n Thread.currentThread().interrupt();\n }\n });\n\n Thread thread2 = new Thread(() -> {\n try {\n String message = \"Message from thread2\";\n String response = exchanger.exchange(message);\n System.out.println(\"Thread2 received: \" + response);\n } catch (InterruptedException e) {\n Thread.currentThread().interrupt();\n }\n });\n\n thread1.start();\n thread2.start();\n }\n}\n```\n\n\n#### [CompletableFuture 的使用方式了解吗?](#completablefuture-的使用方式了解吗)\n\nCompletableFuture 是 Java 8 引入的一个类,支持异步编程,允许线程在完成计算后将结果传递给其他线程。\n\n\n```java\nclass Main {\n public static void main(String[] args) {\n CompletableFuture future = CompletableFuture.supplyAsync(() -> {\n // 模拟长时间计算\n return \"Message from CompletableFuture\";\n });\n\n future.thenAccept(message -> {\n System.out.println(\"Received: \" + message);\n });\n }\n}\n```"
+ },
+ {
+ "id": 96,
+ "question": "请说说 sleep 和 wait 的区别?(补充)",
+ "answer": "> 2024 年 03 月 21 日增补\n\nsleep 会让当前线程休眠,不需要获取对象锁,属于 Thread 类的方法;wait 会让获得对象锁的线程等待,要提前获得对象锁,属于 Object 类的方法。\n\n详细解释下。\n\n①、所属类不同\n\n* `sleep()` 方法专属于 `Thread` 类。\n* `wait()` 方法专属于 `Object` 类。\n\n②、锁行为不同\n\n如果一个线程在持有某个对象锁时调用了 sleep 方法,它在睡眠期间仍然会持有这个锁。\n\n\n```java\nclass SleepDoesNotReleaseLock {\n\n private static final Object lock = new Object();\n\n public static void main(String[] args) throws InterruptedException {\n Thread sleepingThread = new Thread(() -> {\n synchronized (lock) {\n System.out.println(\"Thread 1 会继续持有锁,并且进入睡眠状态\");\n try {\n Thread.sleep(5000);\n } catch (InterruptedException e) {\n e.printStackTrace();\n }\n System.out.println(\"Thread 1 醒来了,并且释放了锁\");\n }\n });\n\n Thread waitingThread = new Thread(() -> {\n synchronized (lock) {\n System.out.println(\"Thread 2 进入同步代码块\");\n }\n });\n\n sleepingThread.start();\n Thread.sleep(1000);\n waitingThread.start();\n }\n}\n```\n\n\n输出结果:\n\n\n```text\nThread 1 会继续持有锁,并且进入睡眠状态\nThread 1 醒来了,并且释放了锁\nThread 2 进入同步代码块\n```\n\n\n从输出中我们可以看到,waitingThread 必须等待 sleepingThread 完成睡眠后才能进入同步代码块。\n\n而当线程执行 wait 方法时,它会释放持有的对象锁,因此其他线程也有机会获取该对象的锁。\n\n\n```java\nclass WaitReleasesLock {\n\n private static final Object lock = new Object();\n\n public static void main(String[] args) throws InterruptedException {\n Thread waitingThread = new Thread(() -> {\n synchronized (lock) {\n try {\n System.out.println(\"Thread 1 持有锁,准备等待 5 秒\");\n lock.wait(5000);\n System.out.println(\"Thread 1 醒来了,并且退出同步代码块\");\n } catch (InterruptedException e) {\n e.printStackTrace();\n }\n }\n });\n\n Thread notifyingThread = new Thread(() -> {\n synchronized (lock) {\n System.out.println(\"Thread 2 尝试唤醒等待中的线程\");\n lock.notify();\n System.out.println(\"Thread 2 执行完了 notify\");\n }\n });\n\n waitingThread.start();\n Thread.sleep(1000);\n notifyingThread.start();\n }\n}\n```\n\n\n输出结果:\n\n\n```text\nThread 1 持有锁,准备等待 5 秒\nThread 2 尝试唤醒等待中的线程\nThread 2 执行完了 notify\nThread 1 醒来了,并且退出同步代码块\n```\n\n\n这表明 waitingThread 在调用 wait 后确实释放了锁。\n\n③、使用条件不同\n\n* `sleep()` 方法可以在任何地方被调用。\n* `wait()` 方法必须在同步代码块或同步方法中被调用,这是因为调用 `wait()` 方法的前提是当前线程必须持有对象的锁。否则会抛出 `IllegalMonitorStateException` 异常。\n\n\n\n④、唤醒方式不同\n\n* 调用 sleep 方法后,线程会进入 TIMED\\_WAITING 状态,即在指定的时间内暂停执行。当指定的时间结束后,线程会自动恢复到 RUNNABLE 状态,等待 CPU 调度再次执行。\n* 调用 wait 方法后,线程会进入 WAITING 状态,直到有其他线程在同一对象上调用 notify 或 notifyAll 方法,线程才会从 WAITING 状态转变为 RUNNABLE 状态,准备再次获得 CPU 的执行权。\n\n我们来通过代码再感受一下 `sleep()` 和 `wait()` 在用法上的区别,先看 `sleep()` 的用法:\n\n\n```java\nclass SleepExample {\n public static void main(String[] args) {\n Thread thread = new Thread(() -> {\n System.out.println(\"线程准备休眠 2 秒\");\n try {\n Thread.sleep(2000); // 线程将睡眠2秒\n } catch (InterruptedException e) {\n e.printStackTrace();\n }\n System.out.println(\"线程醒来了\");\n });\n\n thread.start();\n }\n}\n```\n\n\n再来看 `wait()` 的用法:\n\n\n```java\nclass WaitExample {\n public static void main(String[] args) {\n final Object lock = new Object();\n\n Thread thread = new Thread(() -> {\n synchronized (lock) {\n try {\n System.out.println(\"线程准备等待 2 秒\");\n lock.wait(2000); // 线程会等待2秒,或者直到其他线程调用 lock.notify()/notifyAll()\n System.out.println(\"线程结束等待\");\n } catch (InterruptedException e) {\n e.printStackTrace();\n }\n }\n });\n\n thread.start();\n }\n}\n```"
+ },
+ {
+ "id": 97,
+ "question": "怎么保证线程安全?(补充)",
+ "answer": "> 2024 年 05 月 01 日增补\n\n线程安全是指在并发环境下,多个线程访问共享资源时,程序能够正确地执行,而不会出现数据不一致的问题。\n\n为了保证线程安全,可以使用 对方法加锁,对代码块加锁。线程在执行同步方法、同步代码块时,会获取类锁或者对象锁,其他线程就会阻塞并等待锁。\n\n如果需要更细粒度的锁,可以使用 等。\n\n如果需要保证变量的内存可见性,可以使用 。\n\n对于简单的原子变量操作,还可以使用 。\n\n对于线程独立的数据,可以使用 来为每个线程提供专属的变量副本。\n\n对于需要并发容器的地方,可以使用 、 等。\n\n#### [有个int的变量为0,十个线程轮流对其进行++操作(循环10000次),结果大于10 万还是小于等于10万,为什么?](#有个int的变量为0-十个线程轮流对其进行-操作-循环10000次-结果大于10-万还是小于等于10万-为什么)\n\n在这个场景中,最终的结果会小于 100000,原因是多线程环境下,++ 操作并不是一个原子操作,而是分为读取、加 1、写回三个步骤。\n\n1. 读取变量的值。\n2. 将读取到的值加 1。\n3. 将结果写回变量。\n\n这样的话,就会有多个线程读取到相同的值,然后对这个值进行加 1 操作,最终导致结果小于 100000。\n\n详细解释下。\n\n多个线程在并发执行 ++ 操作时,可能出现以下竞态条件:\n\n* 线程 1 读取变量值为 0。\n* 线程 2 也读取变量值为 0。\n* 线程 1 进行加法运算并将结果 1 写回变量。\n* 线程 2 进行加法运算并将结果 1 写回变量,覆盖了线程 1 的结果。\n\n可以通过 synchronized 关键字为 ++ 操作加锁。\n\n\n```java\nclass Main {\n private static int count = 0;\n\n public static void main(String[] args) throws InterruptedException {\n Runnable task = () -> {\n for (int i = 0; i < 10000; i++) {\n synchronized (Main.class) {\n count++;\n }\n }\n };\n\n List threads = new ArrayList<>();\n for (int i = 0; i < 10; i++) {\n Thread thread = new Thread(task);\n threads.add(thread);\n thread.start();\n }\n\n for (Thread thread : threads) {\n thread.join();\n }\n\n System.out.println(\"Final count: \" + count);\n }\n}\n```\n\n\n或者使用 AtomicInteger 的 `incrementAndGet()` 方法来替代 ++ 操作,保证变量的原子性。\n\n\n```java\nclass Main {\n private static AtomicInteger count = new AtomicInteger(0);\n\n public static void main(String[] args) throws InterruptedException {\n Runnable task = () -> {\n for (int i = 0; i < 10000; i++) {\n count.incrementAndGet();\n }\n };\n\n List threads = new ArrayList<>();\n for (int i = 0; i < 10; i++) {\n Thread thread = new Thread(task);\n threads.add(thread);\n thread.start();\n }\n\n for (Thread thread : threads) {\n thread.join();\n }\n\n System.out.println(\"Final count: \" + count.get());\n }\n}\n```\n\n\n#### [场景:有一个 key 对应的 value 是一个json 结构,json 当中有好几个子任务,这些子任务如果对 key 进行修改的话,会不会存在线程安全的问题?](#场景-有一个-key-对应的-value-是一个json-结构-json-当中有好几个子任务-这些子任务如果对-key-进行修改的话-会不会存在线程安全的问题)\n\n会。\n\n在单节点环境中,可以使用 synchronized 关键字或 ReentrantLock 来保证对 key 的修改操作是原子的。\n\n\n```java\nclass KeyManager {\n private final ReentrantLock lock = new ReentrantLock();\n\n private String key = \"{\\\"tasks\\\": [\\\"task1\\\", \\\"task2\\\"]}\";\n\n public String readKey() {\n lock.lock();\n try {\n return key;\n } finally {\n lock.unlock();\n }\n }\n\n public void updateKey(String newKey) {\n lock.lock();\n try {\n this.key = newKey;\n } finally {\n lock.unlock();\n }\n }\n}\n```\n\n\n在多节点环境中,可以使用分布式锁 Redisson 来保证对 key 的修改操作是原子的。\n\n\n```java\nclass DistributedKeyManager {\n private final RedissonClient redisson;\n\n public DistributedKeyManager() {\n Config config = new Config();\n config.useSingleServer().setAddress(\"redis://127.0.0.1:6379\");\n this.redisson = Redisson.create(config);\n }\n\n public void updateKey(String key, String newValue) {\n RLock lock = redisson.getLock(key);\n lock.lock();\n try {\n // 模拟读取和更新操作\n String currentValue = readFromDatabase(key); // 假设读取 JSON 数据\n String updatedValue = modifyJson(currentValue, newValue); // 修改 JSON\n writeToDatabase(key, updatedValue); // 写回数据库\n } finally {\n lock.unlock();\n }\n }\n\n private String readFromDatabase(String key) {\n // 模拟从数据库读取\n return \"{\\\"tasks\\\": [\\\"task1\\\", \\\"task2\\\"]}\";\n }\n\n private String modifyJson(String json, String newValue) {\n // 使用 JSON 库解析并修改\n return json.replace(\"task1\", newValue);\n }\n\n private void writeToDatabase(String key, String value) {\n // 模拟写回数据库\n }\n}\n```\n\n\n#### [说一个线程安全的使用场景?](#说一个线程安全的使用场景)\n\n单例模式。在多线程环境下,如果多个线程同时尝试创建实例,单例类必须确保只创建一个实例,并提供一个全局访问点。\n\n饿汉式是一种比较直接的实现方式,它通过在类加载时就立即初始化单例对象来保证线程安全。\n\n\n```java\nclass Singleton {\n private static final Singleton instance = new Singleton();\n\n private Singleton() {\n }\n\n public static Singleton getInstance() {\n return instance;\n }\n}\n```\n\n\n懒汉式单例则在第一次使用时初始化单例对象,这种方式需要使用双重检查锁定来确保线程安全,volatile 关键字用来保证可见性,syncronized 关键字用来保证同步。\n\n\n```java\nclass LazySingleton {\n private static volatile LazySingleton instance;\n\n private LazySingleton() {}\n\n public static LazySingleton getInstance() {\n if (instance == null) { // 第一次检查\n synchronized (LazySingleton.class) {\n if (instance == null) { // 第二次检查\n instance = new LazySingleton();\n }\n }\n }\n return instance;\n }\n}\n```\n\n\n#### [能说一下 Hashtable 的底层数据结构吗?](#能说一下-hashtable-的底层数据结构吗)\n\n与 HashMap 类似,Hashtable 的底层数据结构也是一个数组加上链表的方式,然后通过 synchronized 加锁来保证线程安全。\n\n"
+ }
+ ]
+ },
+ {
+ "id": 20,
+ "categoryName": "ThreadLocal",
+ "questions": [
+ {
+ "id": 98,
+ "question": "ThreadLocal 是什么?",
+ "answer": "是一种用于实现线程局部变量的工具类。它允许每个线程都拥有自己的独立副本,从而实现线程隔离。\n\n\n\n使用 ThreadLocal 通常分为四步:\n\n①、创建 ThreadLocal\n\n\n```java\n//创建一个ThreadLocal变量\npublic static ThreadLocal localVariable = new ThreadLocal<>();\n```\n\n\n②、设置 ThreadLocal 的值\n\n\n```java\n//设置ThreadLocal变量的值\nlocalVariable.set(\"练习伴侣二是沙雕\");\n```\n\n\n③、获取 ThreadLocal 的值\n\n\n```java\n//获取ThreadLocal变量的值\nString value = localVariable.get();\n```\n\n\n④、删除 ThreadLocal 的值\n\n\n```java\n//删除ThreadLocal变量的值\nlocalVariable.remove();\n```\n\n\n在 Web 应用中,可以使用 ThreadLocal 存储用户会话信息,这样每个线程在处理用户请求时都能方便地访问当前用户的会话信息。\n\n在数据库操作中,可以使用 ThreadLocal 存储数据库连接对象,每个线程有自己独立的数据库连接,从而避免了多线程竞争同一数据库连接的问题。\n\n在格式化操作中,例如日期格式化,可以使用 ThreadLocal 存储 SimpleDateFormat 实例,避免多线程共享同一实例导致的线程安全问题。\n\n#### [ThreadLocal 有哪些优点?](#threadlocal-有哪些优点)\n\n每个线程访问的变量副本都是独立的,避免了共享变量引起的线程安全问题。由于 ThreadLocal 实现了变量的线程独占,使得变量不需要同步处理,因此能够避免资源竞争。\n\nThreadLocal 可用于跨方法、跨类时传递上下文数据,不需要在方法间传递参数。"
+ },
+ {
+ "id": 99,
+ "question": "你在工作中用到过 ThreadLocal 吗?",
+ "answer": "有用到过,用来存储用户信息。\n\n是典型的 MVC 架构,登录后的用户每次访问接口,都会在请求头中携带一个 token,在控制层可以根据这个 token,解析出用户的基本信息。\n\n假如在服务层和持久层也要用到用户信息,就可以在控制层拦截请求把用户信息存入 ThreadLocal。\n\n这样我们在任何一个地方,都可以取出 ThreadLocal 中存的用户信息。\n\n很多其它场景的 cookie、session 等等数据隔离都可以通过 ThreadLocal 去实现。\n\n"
+ },
+ {
+ "id": 100,
+ "question": "ThreadLocal 怎么实现的呢?",
+ "answer": "当我们创建一个 ThreadLocal 对象并调用 set 方法时,其实是在当前线程中初始化了一个 ThreadLocalMap。\n\n\n\nThreadLocalMap 是 ThreadLocal 的一个静态内部类,它内部维护了一个 Entry 数组,key 是 ThreadLocal 对象,value 是线程的局部变量,这样就相当于为每个线程维护了一个变量副本。\n\n\n\nEntry 继承了 WeakReference,它限定了 key 是一个弱引用,弱引用的好处是当内存不足时,JVM 会回收 ThreadLocal 对象,并且将其对应的 Entry.value 设置为 null,这样可以在很大程度上避免内存泄漏。\n\n\n```java\nstatic class Entry extends WeakReference> {\n /** The value associated with this ThreadLocal. */\n Object value;\n\n //节点类\n Entry(ThreadLocal> k, Object v) {\n //key赋值\n super(k);\n //value赋值\n value = v;\n }\n}\n```\n\n\n总结一下:\n\nThreadLocal 的实现原理是,每个线程维护一个 Map,key 为 ThreadLocal 对象,value 为想要实现线程隔离的对象。\n\n1、通过 ThreadLocal 的 set 方法将对象存入 Map 中。\n\n2、通过 ThreadLocal 的 get 方法从 Map 中取出对象。\n\n3、Map 的大小由 ThreadLocal 对象的多少决定。\n\n\n\n#### [什么是弱引用,什么是强引用?](#什么是弱引用-什么是强引用)\n\n我先说一下强引用,比如 `User user = new User(\"练习伴侣二\")` 中,user 就是一个强引用,`new User(\"练习伴侣二\")` 就是强引用对象。\n\n当 user 被置为 null 时(`user = null`),`new User(\"练习伴侣二\")` 对象就会被垃圾回收;否则即便是内存空间不足,JVM 也不会回收 `new User(\"练习伴侣二\")` 这个强引用对象,宁愿抛出 OutOfMemoryError。\n\n弱引用,比如说在使用 ThreadLocal 中,Entry 的 key 就是一个弱引用对象。\n\n\n```java\nThreadLocal userThreadLocal = new ThreadLocal<>();\nuserThreadLocal.set(new User(\"练习伴侣二\"));\n```\n\n\nuserThreadLocal 是一个强引用,`new ThreadLocal<>()` 是一个强引用对象;\n\n`new User(\"练习伴侣二\")` 是一个强引用对象。\n\n调用 set 方法后,会将 `key = new ThreadLocal<>()` 放入 ThreadLocalMap 中,此时的 key 是一个弱引用对象。当 JVM 进行垃圾回收时,如果发现了弱引用对象,就会将其回收。\n\n\n\n其关系链就是:\n\n* ThreadLocal 强引用 -> ThreadLocal 对象。\n* Thread 强引用 -> ThreadLocalMap。\n* `ThreadLocalMap[i]` 强引用了 -> Entry。\n* Entry.key 弱引用 -> ThreadLocal 对象。\n* Entry.value 强引用 -> 线程的局部变量对象。"
+ },
+ {
+ "id": 101,
+ "question": "ThreadLocal 内存泄露是怎么回事?",
+ "answer": "ThreadLocalMap 的 Key 是 弱引用,但 Value 是强引用。\n\n如果一个线程一直在运行,并且 value 一直指向某个强引用对象,那么这个对象就不会被回收,从而导致内存泄漏。\n\n\n\n#### [那怎么解决内存泄漏问题呢?](#那怎么解决内存泄漏问题呢)\n\n很简单,使用完 ThreadLocal 后,及时调用 `remove()` 方法释放内存空间。\n\n\n```java\ntry {\n threadLocal.set(value);\n // 执行业务操作\n} finally {\n threadLocal.remove(); // 确保能够执行清理\n}\n```\n\n\n`remove()` 方法会将当前线程的 ThreadLocalMap 中的所有 key 为 null 的 Entry 全部清除,这样就能避免内存泄漏问题。\n\n\n```java\nprivate void remove(ThreadLocal> key) {\n Entry[] tab = table;\n int len = tab.length;\n // 计算 key 的 hash 值\n int i = key.threadLocalHashCode & (len-1);\n // 遍历数组,找到 key 为 null 的 Entry\n for (Entry e = tab[i];\n e != null;\n e = tab[i = nextIndex(i, len)]) {\n if (e.get() == key) {\n // 将 key 为 null 的 Entry 清除\n e.clear();\n expungeStaleEntry(i);\n return;\n }\n }\n}\n\npublic void clear() {\n this.referent = null;\n}\n```\n\n\n#### [那为什么 key 要设计成弱引用?](#那为什么-key-要设计成弱引用)\n\n弱引用的好处是,当内存不足的时候,JVM 能够及时回收掉弱引用的对象。\n\n比如说:\n\n\n```java\nWeakReference key = new WeakReference(new ThreadLocal());\n```\n\n\nkey 是弱引用,`new WeakReference(new ThreadLocal())` 是弱引用对象,当 JVM 进行垃圾回收时,只要发现了弱引用对象,就会将其回收。\n\n一旦 key 被回收,ThreadLocalMap 在进行 set、get 的时候就会对 key 为 null 的 Entry 进行清理。\n\n\n\n总结一下,在 ThreadLocal 被垃圾收集后,下一次访问 ThreadLocalMap 时,Java 会自动清理那些键为 null 的 entry,这个过程会在执行 `get()`、`set()`、`remove()`时触发。\n\n\n\n#### [你了解哪些 ThreadLocal 的改进方案?](#你了解哪些-threadlocal-的改进方案)\n\n在 JDK 20 Early-Access Build 28 版本中,出现了 ThreadLocal 的改进方案,即 `ScopedValue`。\n\n还有 Netty 中的 FastThreadLocal,它是 Netty 对 ThreadLocal 的优化,内部维护了一个索引常量 index,每次创建 FastThreadLocal 中都会自动+1,用来取代 hash 冲突带来的损耗,用空间换时间。\n\n\n```java\nprivate final int index;\n\npublic FastThreadLocal() {\n index = InternalThreadLocalMap.nextVariableIndex();\n}\npublic static int nextVariableIndex() {\n int index = nextIndex.getAndIncrement();\n if (index < 0) {\n nextIndex.decrementAndGet();\n }\n return index;\n}\n```\n\n\n以及阿里的 TransmittableThreadLocal,不仅实现了子线程可以继承父线程 ThreadLocal 的功能,并且还可以跨线程池传递值。\n\n\n```java\nTransmittableThreadLocal context = new TransmittableThreadLocal<>();\n\n// 在父线程中设置\ncontext.set(\"value-set-in-parent\");\n\n// 在子线程中可以读取,值是\"value-set-in-parent\"\nString value = context.get();\n```"
+ },
+ {
+ "id": 102,
+ "question": "ThreadLocalMap 的源码看过吗?",
+ "answer": "有研究过。\n\nThreadLocalMap 虽然被叫做 Map,但它并没有实现 Map 接口,是一个简单的线性探测哈希表。\n\n\n```java\nstatic class ThreadLocalMap {\n static class Entry extends WeakReference> {\n Object value;\n\n Entry(ThreadLocal> k, Object v) {\n super(k); // 这里的 Key 是 WeakReference\n value = v;\n }\n }\n\n private Entry[] table; // 存储 ThreadLocal 变量的数组\n private int size; // 当前 Entry 数量\n private int threshold; // 触发扩容的阈值\n}\n```\n\n\n底层的数据结构也是数组,数组中的每个元素是一个 Entry 对象,Entry 对象继承了 WeakReference,key 是 ThreadLocal 对象,value 是线程的局部变量。\n\n\n\n当调用 `ThreadLocal.set(value)` 时,会将 value 存入 ThreadLocalMap。\n\n\n```java\npublic void set(T value) {\n Thread t = Thread.currentThread();\n ThreadLocalMap map = getMap(t);\n if (map != null) {\n map.set(this, value);\n } else {\n createMap(t, value);\n }\n}\n```\n\n\n`set()` 方法是 ThreadLocalMap 的核心方法,通过 key 的哈希码与数组长度取模,计算出 key 在数组中的位置,这一点和 HashMap 的实现类似。\n\n\n```java\nprivate void set(ThreadLocal> key, Object value) {\n Entry[] tab = table;\n int len = tab.length;\n int i = key.threadLocalHashCode & (len - 1); // 计算索引\n\n for (Entry e = tab[i]; e != null; e = tab[nextIndex(i, len)]) {\n ThreadLocal> k = e.get();\n if (k == key) { // 如果 key 已存在,更新 value\n e.value = value;\n return;\n }\n if (k == null) { // Key 为 null,清理无效 Entry\n replaceStaleEntry(key, value, i);\n return;\n }\n }\n \n tab[i] = new Entry(key, value); // 直接插入 Entry\n size++;\n if (size >= threshold) {\n rehash();\n }\n}\n```\n\n\nthreadLocalHashCode 的计算有点东西,每创建一个 ThreadLocal 对象,它就会新增一个**黄金分割数**,可以让哈希码**分布的非常均匀**。\n\n\n```java\nprivate static final int HASH_INCREMENT = 0x61c88647;\n\nprivate static int nextHashCode() {\n return nextHashCode.getAndAdd(HASH_INCREMENT);\n}\n```\n\n\n当调用 `ThreadLocal.get()` 时,会调用 ThreadLocalMap 的 `getEntry()` 方法,根据 key 的哈希码找到对应的线程局部变量。\n\n\n```java\nprivate Entry getEntry(ThreadLocal> key) {\n int i = key.threadLocalHashCode & (table.length - 1);\n Entry e = table[i];\n\n if (e != null && e.get() == key) { // 如果 key 存在,直接返回\n return e;\n } else {\n return getEntryAfterMiss(key, i, e); // 继续查找\n }\n}\n```\n\n\n当调用 `ThreadLocal.remove()` 时,会调用 ThreadLocalMap 的 `remove()` 方法,根据 key 的哈希码找到对应的线程局部变量,将其清除,防止内存泄漏。\n\n\n```java\nprivate void remove(ThreadLocal> key) {\n Entry[] tab = table;\n int len = tab.length;\n int i = key.threadLocalHashCode & (len - 1);\n \n for (Entry e = tab[i]; e != null; e = tab[nextIndex(i, len)]) {\n if (e.get() == key) {\n e.clear(); // 清除 WeakReference\n e.value = null; // 释放 Value\n expungeStaleEntries();\n return;\n }\n }\n}\n```"
+ },
+ {
+ "id": 103,
+ "question": "ThreadLocalMap 怎么解决 Hash 冲突的?",
+ "answer": "**开放定址法**。\n\n如果计算得到的槽位 i 已经被占用,ThreadLocalMap 会采用开放地址法中的线性探测来寻找下一个空闲槽位:\n\n如果 i 位置被占用,尝试 i+1。\n\n如果 i+1 也被占用,继续探测 i+2,直到找到一个空位。\n\n如果到达数组末尾,则回到数组头部,继续寻找空位。\n\n\n```java\nprivate static int nextIndex(int i, int len) {\n return ((i + 1 < len) ? i + 1 : 0);\n}\n```\n\n\n#### [为什么要用线性探测法而不是HashMap 的拉链法来解决哈希冲突?](#为什么要用线性探测法而不是hashmap-的拉链法来解决哈希冲突)\n\nThreadLocalMap 设计的目的是存储线程私有数据,不会有大量的 Key,所以采用线性探测更节省空间。\n\n拉链法还需要单独维护一个链表,甚至红黑树,不适合 ThreadLocal 这种场景。\n\n#### [开放地址法了解吗?](#开放地址法了解吗)\n\n简单来说,就是这个坑被人占了,那就接着去找空着的坑。\n\n\n\n如果我们插入一个 value=27 的数据,通过 hash 计算后应该落入第 4 个槽位,而槽位 4 已经有数据了,而且 key 和当前的不等。\n\n此时就会线性向后查找,一直找到 Entry 为 null 的槽位才会停止。"
+ },
+ {
+ "id": 104,
+ "question": "ThreadLocalMap 扩容机制了解吗?",
+ "answer": "了解。\n\n与 HashMap 不同,ThreadLocalMap 并不会直接在元素数量达到阈值时立即扩容,而是先清理被 GC 回收的 key,然后在填充率达到四分之三时进行扩容。\n\n\n```java\nprivate void rehash() {\n // 清理被 GC 回收的 key\n expungeStaleEntries();\n\n //扩容\n if (size >= threshold - threshold / 4)\n resize();\n}\n```\n\n\n清理过程会遍历整个数组,将 key 为 null 的 Entry 清除。\n\n\n```java\nprivate void expungeStaleEntries() {\n Entry[] tab = table;\n int len = tab.length;\n for (int j = 0; j < len; j++) {\n Entry e = tab[j];\n // 如果 key 为 null,清理 Entry\n if (e != null && e.get() == null)\n expungeStaleEntry(j);\n }\n}\n```\n\n\n阈值 threshold 的默认值是数组长度的三分之二。\n\n\n```java\nprivate void setThreshold(int len) {\n threshold = len * 2 / 3;\n}\n```\n\n\n扩容时,会将数组长度翻倍,然后重新计算每个 Entry 的位置,采用线性探测法来寻找新的空位,然后将 Entry 放入新的数组中。\n\n\n```java\nprivate void resize() {\n Entry[] oldTab = table;\n int oldLen = oldTab.length;\n // 扩容为原来的两倍\n int newLen = oldLen * 2;\n Entry[] newTab = new Entry[newLen];\n \n int count = 0;\n // 遍历老数组\n for (int j = 0; j < oldLen; ++j) {\n Entry e = oldTab[j];\n if (e != null) {\n ThreadLocal> k = e.get();\n if (k == null) {\n e.value = null; // 释放 Value,防止内存泄漏\n } else {\n // 重新计算位置\n int h = k.threadLocalHashCode & (newLen - 1);\n while (newTab[h] != null) {\n // 线性探测寻找新位置\n h = nextIndex(h, newLen);\n }\n // 放入新数组\n newTab[h] = e;\n count++;\n }\n }\n }\n table = newTab;\n size = count;\n threshold = newLen * 2 / 3; // 重新计算扩容阈值\n}\n```\n\n\n一句话总结:ThreadLocalMap 采用的是“先清理再扩容”的策略,扩容时,数组长度翻倍,并重新计算索引,如果发生哈希冲突,采用线性探测法来解决。\n\n"
+ },
+ {
+ "id": 105,
+ "question": "父线程能用 ThreadLocal 给子线程传值吗?",
+ "answer": "不能。\n\n\n\n因为 ThreadLocal 变量存储在每个线程的 ThreadLocalMap 中,而子线程不会继承父线程的 ThreadLocalMap。\n\n可以使用 `InheritableThreadLocal`来解决这个问题。\n\n\n\n子线程在创建的时候会拷贝父线程的 InheritableThreadLocal 变量。\n\n\n\n来看一下使用示例:\n\n\n```java\nclass InheritableThreadLocalExample {\n private static final InheritableThreadLocal inheritableThreadLocal = new InheritableThreadLocal<>();\n\n public static void main(String[] args) {\n inheritableThreadLocal.set(\"父线程的值\");\n\n new Thread(() -> {\n System.out.println(\"子线程获取的值:\" + inheritableThreadLocal.get()); // 继承了父线程的值\n }).start();\n }\n}\n```\n\n\n#### [InheritableThreadLocal的原理了解吗?](#inheritablethreadlocal的原理了解吗)\n\n了解。\n\n在 Thread 类的定义中,每个线程都有两个 ThreadLocalMap:\n\n\n```java\npublic class Thread {\n /* 普通 ThreadLocal 变量存储的地方 */\n ThreadLocal.ThreadLocalMap threadLocals = null;\n\n /* InheritableThreadLocal 变量存储的地方 */\n ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;\n}\n```\n\n\n普通 ThreadLocal 变量存储在 threadLocals 中,不会被子线程继承。\n\nInheritableThreadLocal 变量存储在 inheritableThreadLocals 中,当 `new Thread()` 创建一个子线程时,Thread 的 `init()` 方法会检查父线程是否有 inheritableThreadLocals,如果有,就会拷贝 InheritableThreadLocal 变量到子线程:\n\n\n```java\nprivate void init(ThreadGroup g, Runnable target, String name, long stackSize) {\n // 获取当前父线程\n Thread parent = currentThread();\n // 复制 InheritableThreadLocal 变量\n if (parent.inheritableThreadLocals != null) {\n this.inheritableThreadLocals = \n ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);\n }\n}\n```"
+ }
+ ]
+ },
+ {
+ "id": 21,
+ "categoryName": "Java 内存模型",
+ "questions": [
+ {
+ "id": 106,
+ "question": "说一下你对 Java 内存模型的理解?",
+ "answer": "Java 内存模型是 Java 虚拟机规范中定义的一个抽象模型,用来描述多线程环境中共享变量的内存可见性。\n\n\n\n共享变量存储在`主内存`中,每个线程都有一个私有的`本地内存`,存储了共享变量的副本。\n\n* 当一个线程更改了本地内存中共享变量的副本,它需要 JVM 刷新到主内存中,以确保其他线程可以看到这些更改。\n* 当一个线程需要读取共享变量时,它一版会从本地内存中读取。如果本地内存中的副本是过时的,JVM 会将主内存中的共享变量最新值刷新到本地内存中。\n\n\n\n#### [为什么线程要用自己的内存?](#为什么线程要用自己的内存)\n\n线程从主内存拷贝变量到工作内存,可以减少 CPU 访问 RAM 的开销。\n\n每个线程都有自己的变量副本,可以避免多个线程同时修改共享变量导致的数据冲突。"
+ },
+ {
+ "id": 107,
+ "question": "i++是原子操作吗?",
+ "answer": "不是,它包括三个步骤:\n\n1. 从内存中读取 i 的值。\n2. 对 i 进行加 1 操作。\n3. 将新的值写回内存。\n\n#### [说说你对原子性、可见性、有序性的理解?](#说说你对原子性、可见性、有序性的理解)\n\n**原子性**要求一个操作是不可分割的,要么全部执行成功,要么完全不执行。\n\n举个例子:就比如说 `count++` 就不是一个原子操作,它包括读取 count 的值、加 1、写回 count 三个步骤,所以需要加锁或者使用`AtomicInteger`代替 int 来保证原子性。\n\n**可见性**要求一个线程对共享变量的修改,能够被其他线程及时看见。\n\n我通过下面的代码解释一下:\n\n\n```java\nprivate static boolean flag = true;\n\npublic static void main(String[] args) {\n new Thread(() -> {\n while (flag) {} // 线程 A 可能一直看不到 flag=false\n System.out.println(\"线程 A 退出\");\n }).start();\n\n try { Thread.sleep(1000); } catch (InterruptedException e) {}\n\n flag = false; // 线程 B 修改 flag\n}\n```\n\n\n线程 A 会在本地内存中缓存 `flag=true`,虽然线程 B 修改了 `flag=false`,但不会立即同步到主内存以及线程 A 的本地内存,因此线程 A 会一直处于死循环。\n\n解决办法就是通过 volatile 关键字来保证可见性。\n\n**有序性**是指程序执行的顺序是否按照代码编写的顺序执行。\n\n在单线程环境下,代码能够准确无误地按照编写顺序执行。但在多线程环境下,CPU 和编译器可能会进行指令重排,代码的执行顺序因此会发生变化。\n\n我通过下面的代码解释一下:\n\n\n```java\nint a = 0, b = 0;\nboolean flag = false;\n\nvoid thread1() {\n a = 1; \n flag = true; // 可能会被 CPU 优化,先执行\n}\n\nvoid thread2() {\n if (flag) {\n System.out.println(a); // 可能打印 0,而不是 1\n }\n}\n```\n\n\n由于指令重排,`flag = true` 可能会在 `a = 1` 之前执行,导致 `thread2()` 读取 `flag=true` 后,a 仍然是 0,出现不符合代码逻辑的情况。\n\n简要回答:\n\n原子性保证操作不可中断,可见性保证变量修改后线程能看到最新值,有序性保证代码执行顺序一致,可以通过 volatile、synchronized 和 CAS 机制来保证这些特性。\n\n#### [下面的代码是原子操作吗?](#下面的代码是原子操作吗)\n\n\n```java\nint i = 2;\nint j = i;\ni++;\ni = i + 1;\n```\n\n\n* 第 1 行代码是基本类型赋值,是原子性操作。\n* 第 2 行先读 i 的值,再赋值给 j,不是原子操作。\n* 第 3 和第 4 行都不是原子操作,都需要先读取 i 的值,再+1,然后再赋值给 i。"
+ },
+ {
+ "id": 108,
+ "question": "说说什么是指令重排?",
+ "answer": "指令重排是指 CPU 或编译器为了提高程序的执行效率,改变代码执行顺序的一种优化技术。\n\n从 Java 源代码到最终执行的指令序列,会经历 3 种重排序:编译器重排序、指令并行重排序、内存系统重排序。\n\n\n\n指令重排可能会导致双重检查锁失效,比如下面的单例模式代码:\n\n\n```java\npublic class Singleton {\n private static Singleton instance;\n\n public static Singleton getInstance() {\n if (instance == null) { // 第一次检查\n synchronized (Singleton.class) {\n if (instance == null) { // 第二次检查\n instance = new Singleton(); // 可能发生指令重排\n }\n }\n }\n return instance;\n }\n}\n```\n\n\n如果线程 A 执行了 `instance = new Singleton();`,但构造方法还没执行完,线程 B 可能会读取到一个未初始化的对象,导致出现空指针异常。\n\n\n\n正确的方式是给 instance 变量加上 `volatile` 关键字,禁止指令重排。\n\n\n```java\nclass Singleton {\n private static volatile Singleton instance;\n\n public static Singleton getInstance() {\n if (instance == null) {\n synchronized (Singleton.class) {\n if (instance == null) {\n instance = new Singleton(); // 由于 volatile,禁止指令重排\n }\n }\n }\n return instance;\n }\n}\n```"
+ },
+ {
+ "id": 109,
+ "question": "happens-before 了解吗?",
+ "answer": "Happens-Before 是 Java 内存模型定义的一种保证线程间可见性和有序性的规则。\n\n如果操作 A Happens-Before 操作 B,那么:\n\n1. 操作 A 的结果对操作 B 可见。\n2. 操作 A 在时间上先于操作 B 执行。\n\n换句话说,如果 A Happens-Before B,那么 A 的修改必须对 B 可见,并且 B 不能重排序到 A 之前。\n\n#### [你知道哪些 Happens-Before 规则?](#你知道哪些-happens-before-规则)\n\n\n\nJMM 规定了 6 种 Happens-Before 规则,满足这些规则的操作不会被重排序,并且保证了数据的可见性。\n\n①、程序顺序规则:单线程内,代码按顺序执行;比如 `a = 1; b = 2;`,a 先于 b 执行。\n\n②、监视器锁定规则:`unlock() Happens-Before lock()`;比如 synchronized 释放锁后,获取锁的线程能够看到最新的数据。\n\n③、volatile 变量规则:写 volatile 变量 Happens-Before 读 volatile。\n\n④、传递性规则:A Happens-Before B 且 B Happens-Before C,则 A Happens-Before C。例如 a = 1 先于 b = 2,b = 2 先于 c = 3,则 a = 1 先于 c = 3。\n\n⑤、线程启动规则:线程 A 执行操作 `ThreadB.start()`,那么 A 线程的 `ThreadB.start()` 操作 happens-before 于线程 B 中的任意操作。\n\n⑥、线程终止规则:线程的所有操作 Happens-Before `Thread.join()`;例如 `t.join();` 之后,主线程一定能看到 t 的修改。"
+ },
+ {
+ "id": 110,
+ "question": "as-if-serial 了解吗?",
+ "answer": "As-If-Serial 规则允许 CPU 和编译器优化代码顺序,但不会改变单线程的执行结果。它只适用于单线程,多线程环境仍然可能发生指令重排,需要 volatile 和 synchronized 等机制来保证有序性。\n\n来解释说明一下。\n\n\n```java\ndouble pi = 3.14; // A\ndouble r = 1.0; // B\ndouble area = pi * r * r; // C\n```\n\n\nC 依赖于 A,同时 C 也依赖着 B。\n\n\n\n因此在最终执行的指令序列中,C 不能被重排序到 A 或者 B 的前面,否则就会出现错误。\n\n但 A 和 B 之间没有依赖关系,因此编译器和处理器可以重排序 A 和 B 之间的执行顺序。\n\n所以程序可能会有两种执行顺序:\n\n\n\nHappens-Before 规则保证了多线程环境下的有序性,防止指令重排导致的并发问题。As-If-Serial 规则保证了单线程代码不会因优化而执行错误。"
+ },
+ {
+ "id": 111,
+ "question": "volatile 了解吗?",
+ "answer": "了解。\n\n第一,保证可见性,线程修改 volatile 变量后,其他线程能够立即看到最新值;第二,防止指令重排,volatile 变量的写入不会被重排序到它之前的代码。\n\n#### [volatile 怎么保证可见性的?](#volatile-怎么保证可见性的)\n\n当线程对 volatile 变量进行写操作时,JVM 会在这个变量写入之后插入一个写屏障指令,这个指令会强制将本地内存中的变量值刷新到主内存中。\n\n\n```java\nStoreStore; // 保证写入之前的操作不会重排\nvolatile_write(); // 写入 volatile 变量\nStoreLoad; // 保证写入后,其他线程立即可见\n```\n\n\n在 x86 架构下,通常会使用 `lock` 指令来实现写屏障,例如:\n\n\n```text\nmov [a], 2 ; 将值 2 写入内存地址 a\nlock add [a], 0 ; lock 指令充当写屏障,确保内存可见性\n```\n\n\n当线程对 volatile 变量进行读操作时,JVM 会插入一个读屏障指令,这个指令会强制让本地内存中的变量值失效,从而重新从主内存中读取最新的值。\n\n\n\n我们来声明一个 volatile 变量 x:\n\n\n```java\nvolatile int x = 0\n```\n\n\n线程 A 对 x 写入后会将其最新的值刷新到主内存中,线程 B 读取 x 时由于本地内存中的 x 失效了,就会从主内存中读取最新的值。\n\n\n\n#### [volatile 怎么保证有序性的?](#volatile-怎么保证有序性的)\n\nJVM 会在 volatile 变量的读写前后插入 “内存屏障”,以约束 CPU 和编译器的优化行为:\n\n* StoreStore 屏障可以禁止普通写操作与 volatile 写操作的重排\n* StoreLoad 屏障会禁止 volatile 写与 volatile 读重排\n* LoadLoad 屏障会禁止 volatile 读与后续普通读操作重排\n* LoadStore 屏障会禁止 volatile 读与后续普通写操作重排\n\n#### [volatile 和 synchronized 的区别?](#volatile-和-synchronized-的区别)\n\nvolatile 关键字用于修饰变量,确保该变量的更新操作对所有线程是可见的,即一旦某个线程修改了 volatile 变量,其他线程会立即看到最新的值。\n\nsynchronized 关键字用于修饰方法或代码块,确保同一时刻只有一个线程能够执行该方法或代码块,从而实现互斥访问。\n\n#### [volatile 加在基本类型和对象上的区别?](#volatile-加在基本类型和对象上的区别)\n\n当 `volatile` 用于基本数据类型时,能确保该变量的读写操作是直接从主内存中读取或写入的。\n\n\n```java\nprivate volatile int count = 0;\n```\n\n\n当 `volatile` 用于引用类型时,能确保引用本身的可见性,即确保引用指向的对象地址是最新的。\n\n但是,`volatile` 并不能保证引用对象内部状态的线程安全。\n\n\n```java\nprivate volatile SomeObject obj = new SomeObject();\n```\n\n\n虽然 `volatile` 确保了 `obj` 引用的可见性,但对 `obj` 引用的 `new SomeObject()` 对象并不受 `volatile` 保护。\n\n如果需要保证引用对象内部状态的线程安全,需要使用 `synchronized` 或 `ReentrantLock` 等锁机制。"
+ }
+ ]
+ },
+ {
+ "id": 22,
+ "categoryName": "锁",
+ "questions": [
+ {
+ "id": 112,
+ "question": "synchronized 用过吗?",
+ "answer": "用过,频率还很高。\n\nsynchronized 在 JDK 1.6 之后,进行了锁优化,增加了偏向锁、轻量级锁,大大提升了 synchronized 的性能。\n\n#### [synchronized 上锁的对象是什么?](#synchronized-上锁的对象是什么)\n\nsynchronized 用在普通方法上时,上锁的是执行这个方法的对象。\n\n\n```java\npublic synchronized void increment() {\n this.count++;\n}\n```\n\n\nsynchronized 用在静态方法上时,上锁的是这个类的 Class 对象。\n\n\n```java\npublic static synchronized void increment() {\n count++;\n}\n```\n\n\nsynchronized 用在代码块上时,上锁的是括号中指定的对象,比如说当前对象 this。\n\n\n```java\npublic void increment() {\n synchronized (this) {\n this.count++;\n }\n}\n```"
+ },
+ {
+ "id": 113,
+ "question": "synchronized 的实现原理了解吗?",
+ "answer": "synchronized 依赖 JVM 内部的 Monitor 对象来实现线程同步。使用的时候不用手动去 lock 和 unlock,JVM 会自动加锁和解锁。\n\nsynchronized 加锁代码块时,JVM 会通过 `monitorenter`、`monitorexit` 两个指令来实现同步:\n\n* 前者表示线程正在尝试获取 lock 对象的 Monitor;\n* 后者表示线程执行完了同步代码块,正在释放锁。\n\n使用 `javap -c -s -v -l SynchronizedDemo.class` 反编译 synchronized 代码块时,就能看到这两个指令。\n\n\n\nsynchronized 修饰普通方法时,JVM 会通过 `ACC_SYNCHRONIZED` 标记符来实现同步。\n\n\n\n#### [你对 Monitor 了解多少?](#你对-monitor-了解多少)\n\nMonitor 是 JVM 内置的同步机制,每个对象在内存中都有一个对象头——Mark Word,用于存储锁的状态,以及 Monitor 对象的指针。\n\n\n\nsynchronized 依赖对象头的 Mark Word 进行状态管理,支持无锁、偏向锁、轻量级锁,以及重量级锁。\n\n在 Hotspot 虚拟机中,Monitor 由 ObjectMonitor 实现:\n\n\n```java\nObjectMonitor() {\n _count = 0; // 记录线程获取锁的次数\n _owner = NULL; // 指向持有ObjectMonitor对象的线程\n _WaitSet = NULL; // 处于wait状态的线程,会被加入到_WaitSet\n _cxq = NULL ;\n _EntryList = NULL ; // 处于等待锁block状态的线程,会被加入到该列表\n }\n```\n\n\n* \\_owner:当前持有 ObjectMonitor 的线程,初始值为 null,表示没有线程持有锁。线程成功获取锁后,该值更新为线程 ID,释放锁后重置为 null。\n* \\_count:记录当前线程获取锁的次数(可重入锁),每次成功加锁 `_count + 1`,释放锁 `_count - 1`。\n* \\_WaitSet:等待队列,调用 `wait()` 方法后,线程会释放锁,并加入 \\_WaitSet,进入 WAITING 状态,等待 `notify()` 唤醒。\n* \\_cxq:阻塞队列,用于存放刚进入 Monitor 的线程(还未进入 \\_EntryList)。\n* \\_EntryList:竞争队列,所有等待获取锁的线程(BLOCKED 状态)会进入 \\_EntryList,等待锁释放后竞争执行权。\n\n结构示意图:\n\n\n```text\n+----------------------+\n| ObjectMonitor |\n| ---------------- |\n| _owner = Thread-1 | // 当前持有锁的线程\n| _count = 1 | // 线程获取锁的次数\n| _WaitSet -> T3,T4 | // 执行 wait() 的线程\n| _EntryList -> T2,T5| // 竞争锁的线程\n| _cxq -> T6,T7 | // 新进入的线程\n+----------------------+\n```\n\n\n#### [会不会牵扯到 os 层面呢?](#会不会牵扯到-os-层面呢)\n\n会,synchronized 升级为重量级锁时,依赖于操作系统的互斥量——mutex 来实现,mutex 用于保证任何给定时间内,只有一个线程可以执行某一段特定的代码段。"
+ },
+ {
+ "id": 114,
+ "question": "synchronized 怎么保证可见性?",
+ "answer": "通过两步操作:\n\n* 加锁时,线程必须从主内存读取最新数据。\n* 释放锁时,线程必须将修改的数据刷回主内存,这样其他线程获取锁后,就能看到最新的数据。\n\n\n```text\n线程 A 线程 B\n ┌────────────────────┐\n │ synchronized(lock) │\n │ x = 1; │ // 1. 线程 A 修改变量 x\n └────────────────────┘\n ↓ 释放锁\n (JVM 强制刷新 x 到主内存)\n\n (线程 B 获取锁)\n ┌────────────────────┐\n │ synchronized(lock) │\n │ print(x); │ // 2. 线程 B 读取最新 x=1\n └────────────────────┘\n```\n\n\n#### [synchronized 怎么保证有序性?](#synchronized-怎么保证有序性)\n\nsynchronized 通过 JVM 指令 monitorenter 和 monitorexit,来确保加锁代码块内的指令不会被重排。\n\n来解释一下,比如说对于:\n\n\n```java\nsynchronized (lock) {\n x = 1;\n flag = true;\n}\n```\n\n\njavap 反编译后的伪代码:\n\n\n```java\nmonitorenter // 获取锁\nstore x, 1 // 变量 x = 1\nstore flag, true // 变量 flag = true\nmonitorexit // 释放锁\n```\n\n\n实际 javap 反编译后的结果:\n\n\n\n指令解释一下:\n\n| 指令 | 作用 |\n| --- | --- |\n| monitorenter | 获取锁,进入同步代码块 |\n| iconst\\_1 | 将整数 1 压入操作数栈 |\n| istore\\_1 | 存储 1 到局部变量 x |\n| iconst\\_1 | 再次将整数 1 压入操作数栈 |\n| istore\\_2 | 存储 1 到局部变量 flag |\n| aload 4 | 加载 lock 对象引用 |\n| monitorexit | 释放锁,退出同步代码块 |\n\n#### [synchronized 怎么实现可重入的呢?](#synchronized-怎么实现可重入的呢)\n\n可重入意味着同一个线程可以多次获得同一个锁,而不会被阻塞。\n\n\n\nsynchronized 之所以支持可重入,是因为 Java 的对象头包含了一个 Mark Word,用于存储对象的状态,包括锁信息。\n\n当一个线程获取对象锁时,JVM 会将该线程的 ID 写入 Mark Word,并将锁计数器设为 1。\n\n如果一个线程尝试再次获取已经持有的锁,JVM 会检查 Mark Word 中的线程 ID。如果 ID 匹配,表示的是同一个线程,锁计数器递增。\n\n当线程退出同步块时,锁计数器递减。如果计数器值为零,JVM 将锁标记为未持有状态,并清除线程 ID 信息。\n\n来解释一下:\n\n\n```java\nclass ReentrantExample {\n public synchronized void method1() {\n System.out.println(\"Method1 acquired lock\");\n method2(); // 线程已经持有锁,能继续调用 method2\n }\n\n public synchronized void method2() {\n System.out.println(\"Method2 acquired lock\");\n }\n\n public static void main(String[] args) {\n ReentrantExample example = new ReentrantExample();\n example.method1();\n }\n}\n```\n\n\n执行结果:\n\n\n```text\nMethod1 acquired lock\nMethod2 acquired lock\n```\n\n\n因为 synchronized 支持可重入,所以 method1 获取锁后,method2 仍然可以获取锁。\n\n底层是通过 Monitor 对象的 owner 和 count 字段实现的,owner 记录持有锁的线程,count 记录线程获取锁的次数。\n\n\n```text\n+----------------------+\n| ObjectMonitor |\n| ---------------- |\n| _owner = Thread-1 | // 当前持有锁的线程\n| _count = 2 | // 线程重入了 2 次\n+----------------------+\n```"
+ },
+ {
+ "id": 115,
+ "question": "synchronized 锁升级了解吗?",
+ "answer": "JDK 1.6 的时候,为了提升 synchronized 的性能,引入了锁升级机制,从低开销的锁逐步升级到高开销的锁,以最大程度减少锁的竞争。\n\n\n\n没有线程竞争时,就使用低开销的“偏向锁”,此时没有额外的 CAS 操作;轻度竞争时,使用“轻量级锁”,采用 CAS 自旋,避免线程阻塞;只有在重度竞争时,才使用“重量级锁”,由 Monitor 机制实现,需要线程阻塞。\n\n#### [了解 synchronized 四种锁状态吗?](#了解-synchronized-四种锁状态吗)\n\n了解。\n\n①、无锁状态,对象未被锁定,Mark Word 存储对象的哈希码等信息。\n\n②、偏向锁,当线程第一次获取锁时,会进入偏向模式。Mark Word 会记录线程 ID,后续同一线程再次获取锁时,可以直接进入 synchronized 加锁的代码,无需额外加锁。\n\n\n\n③、轻量级锁,当多个线程在不同时段获取同一把锁,即不存在锁竞争的情况时,JVM 会采用轻量级锁来避免线程阻塞。\n\n未持有锁的线程通过等待锁释放。\n\n\n\n当线程进入 synchronized 加锁的代码时,如果对象的锁状态为偏向锁,也就是锁类型为“01”,偏向锁标记为“0”的状态。\n\n\n\n然后采用 CAS 自旋的方式,尝试将对象头中的 Mark Word 替换为指向 Lock Record 的指针,并将 Lock Record 中的 owner 指针指向对象的 Mark Word。\n\n\n\n如果这个替换动作成功了,线程就拥有了该对象的锁,对象头 Mark Word 的锁标志位会更新为“00”,表示对象处于轻量级锁状态。\n\n④、重量级锁,如果自旋超过一定的次数,或者一个线程持有锁,一个自旋,又有第三个线程进入 synchronized 加锁的代码时,轻量级锁就会升级为重量级锁。\n\n此时,对象头的锁类型会更新为“10”,Mark Word 会存储指向 Monitor 对象的指针,其他等待锁的线程都会进入阻塞状态。\n\n#### [synchronized 做了哪些优化?](#synchronized-做了哪些优化)\n\n在 JDK 1.6 之前,synchronized 是直接调用 ObjectMonitor 的 enter 和 exit 指令实现的,这种锁也被称为**重量级锁**,性能较差。\n\n随着 JDK 版本的更新,synchronized 的性能得到了极大的优化:\n\n**①、偏向锁**:同一个线程可以多次获取同一把锁,无需重复加锁。\n\n**②、轻量级锁**:当没有线程竞争时,通过 CAS 自旋等待锁,避免直接进入阻塞。\n\n**③、锁消除**: 可以在运行时进行代码分析,如果发现某些锁操作不可能被多个线程同时访问,就会对这些锁进行消除,从而减少上锁开销。\n\n#### [请详细说说锁升级的过程?](#请详细说说锁升级的过程)\n\n懵逼状态下的回答:锁升级会从无锁升级为偏向锁,再升级为轻量级锁,最后升级为重量级锁。\n\n\n\n知道一点,但不深入的回答:\n\n\n\n①、偏向锁:当一个线程第一次获取锁时,JVM 会在对象头的 Mark Word 记录这个线程 ID,下次进入 synchronized 时,如果还是同一个线程,可以直接执行,无需额外加锁。\n\n②、轻量级锁:当多个线程尝试获取锁但不是同一个时段,偏向锁会升级为轻量级锁,等待锁的线程通过 CAS 自旋避免进入阻塞状态。\n\n③、重量级锁:如果自旋失败,锁会升级为重量级锁,等待锁的线程会进入阻塞状态,等待监视器 Monitor 进行调度。\n\n详细解释一下:\n\n**①、从无锁到偏向锁:**\n\n当一个线程首次访问同步代码时,如果此对象处于无锁状态且偏向锁未被禁用,JVM 会将该对象头的锁标记改为偏向锁状态,并记录当前线程 ID。此时,对象头中的 Mark Word 中存储了持有偏向锁的线程 ID。\n\n如果另一个线程尝试获取这个已被偏向的锁,JVM 会检查当前持有偏向锁的线程是否活跃。如果持有偏向锁的线程不活跃,可以将锁偏向给新的线程;否则撤销偏向锁,升级为轻量级锁。\n\n**②、偏向锁的轻量级锁:**\n\n进行偏向锁撤销时,会遍历堆栈的所有锁记录,暂停拥有偏向锁的线程,并检查锁对象。如果这个过程中发现有其他线程试图获取这个锁,JVM 会撤销偏向锁,并将锁升级为轻量级锁。\n\n当有两个或以上线程竞争同一个偏向锁时,偏向锁模式不再有效,此时偏向锁会被撤销,对象的锁状态会升级为轻量级锁。\n\n**③、轻量级锁到重量级锁:**\n\n轻量级锁通过自旋来等待锁释放。如果自旋超过预定次数(自旋次数是可调的,并且是自适应的,失败次数多自旋次数就少),表明锁竞争激烈。\n\n当自旋多次失败,或者有线程在等待队列中等待相同的轻量级锁时,轻量级锁会升级为重量级锁。在这种情况下,JVM 会在操作系统层面创建一个互斥锁——Mutex,所有进一步尝试获取该锁的线程将会被阻塞,直到锁被释放。"
+ },
+ {
+ "id": 116,
+ "question": "synchronized 和 ReentrantLock 的区别了解吗?",
+ "answer": "两句话回答: 由 JVM 内部的 Monitor 机制实现,基于 AQS 实现。\n\nsynchronized 可以自动加锁和解锁,ReentrantLock 需要手动 `lock()` 和 `unlock()`。\n\n\n\n如果面试官还想知道更多,可以继续回答:\n\n①、ReentrantLock 可以实现多路选择通知,绑定多个 ,而 synchronized 只能通过 wait 和 notify 唤醒,属于单路通知;\n\n\n```java\nReentrantLock lock = new ReentrantLock();\nCondition condition = lock.newCondition();\n```\n\n\n②、synchronized 可以在方法和代码块上加锁,ReentrantLock 只能在代码块上加锁,但可以指定是公平锁还是非公平锁。\n\n\n```java\n// synchronized 修饰方法\npublic synchronized void method() {\n // 业务代码\n}\n\n// synchronized 修饰代码块\nsynchronized (this) {\n // 业务代码\n}\n\n// ReentrantLock 加锁\nReentrantLock lock = new ReentrantLock();\nlock.lock();\ntry {\n // 业务代码\n} finally {\n lock.unlock();\n}\n```\n\n\n③、ReentrantLock 提供了一种能够中断等待锁的线程机制,通过 `lock.lockInterruptibly()` 来实现。\n\n\n```java\nReentrantLock lock = new ReentrantLock();\ntry {\n lock.lockInterruptibly();\n} catch (InterruptedException e) {\n // 处理中断异常\n}\n```\n\n\n#### [并发量大的情况下,使用 synchronized 还是 ReentrantLock?](#并发量大的情况下-使用-synchronized-还是-reentrantlock)\n\n我更倾向于 ReentrantLock,因为:\n\n* ReentrantLock 提供了超时和公平锁等特性,可以应对更复杂的并发场景。\n* ReentrantLock 允许更细粒度的锁控制,能有效减少锁竞争。\n* ReentrantLock 支持条件变量 Condition,可以实现比 synchronized 更友好的线程间通信机制。\n\n#### [Lock 了解吗?](#lock-了解吗)\n\nLock 是 JUC 中的一个接口,最常用的实现类包括可重入锁 ReentrantLock、读写锁 ReentrantReadWriteLock 等。\n\n#### [ReentrantLock 的 lock() 方法实现逻辑了解吗?](#reentrantlock-的-lock-方法实现逻辑了解吗)\n\nlock 方法的具体实现由 ReentrantLock 内部的 Sync 类来实现,涉及到线程的自旋、阻塞队列、CAS、AQS 等。\n\n\n\nlock 方法会首先尝试通过 CAS 来获取锁。如果当前锁没有被持有,会将锁状态设置为 1,表示锁已被占用。否则,会将当前线程加入到 AQS 的等待队列中。\n\n\n```java\nfinal void lock() {\n if (compareAndSetState(0, 1)) // 尝试直接获取锁\n setExclusiveOwnerThread(Thread.currentThread());\n else\n acquire(1); // 如果获取失败,进入AQS队列等待\n}\n```"
+ },
+ {
+ "id": 117,
+ "question": "AQS 了解多少?",
+ "answer": "AQS 是一个抽象类,它维护了一个共享变量 state 和一个线程等待队列,为 ReentrantLock 等类提供底层支持。\n\n\n\nAQS 的思想是,如果被请求的共享资源处于空闲状态,则当前线程成功获取锁;否则,将当前线程加入到等待队列中,当其他线程释放锁时,从等待队列中挑选一个线程,把锁分配给它。\n\n#### [AQS 的源码阅读过吗?](#aqs-的源码阅读过吗)\n\n有研究过。\n\n第一,状态 state 由 volatile 变量修饰,用于保证多线程之间的可见性;\n\n\n```java\nprivate volatile int state;\n```\n\n\n②、同步队列由内部定义的 Node 类实现,每个 Node 包含了等待状态、前后节点、线程的引用等,是一个先进先出的双向链表。\n\n\n```java\nstatic final class Node {\n static final int CANCELLED = 1;\n static final int SIGNAL = -1;\n static final int CONDITION = -2;\n static final int PROPAGATE = -3;\n\n volatile Node prev;\n\n volatile Node next;\n\n volatile Thread thread;\n}\n```\n\n\nAQS 支持两种同步方式:\n\n* 独占模式下:每次只能有一个线程持有锁,例如 ReentrantLock。\n* 共享模式下:多个线程可以同时获取锁,例如 Semaphore 和 CountDownLatch。\n\n核心方法包括:\n\n* `acquire`:获取锁,失败进入等待队列;\n* `release`:释放锁,唤醒等待队列中的线程;\n* `acquireShared`:共享模式获取锁;\n* `releaseShared`:共享模式释放锁。\n\nAQS 使用一个 CLH 队列来维护等待线程,CLH 是三个作者 Craig、Landin 和 Hagersten 的首字母缩写,是一种基于链表的自旋锁。\n\n\n\n在 CLH 中,当一个线程尝试获取锁失败后,会被添加到队列的尾部并自旋,等待前一个节点的线程释放锁。\n\n\n\nCLH 的优点是,假设有 100 个线程在等待锁,锁释放之后,只会通知队列中的第一个线程去竞争锁。避免同时唤醒大量线程,浪费 CPU 资源。"
+ },
+ {
+ "id": 118,
+ "question": "说说 ReentrantLock 的实现原理?",
+ "answer": "是基于 AQS 实现的 可重入排他锁,使用 CAS 尝试获取锁,失败的话,会进入 CLH 阻塞队列,支持公平锁、非公平锁,可以中断、超时等待。\n\n\n\n内部通过一个计数器 state 来跟踪锁的状态和持有次数。当线程调用 `lock()` 方法获取锁时,ReentrantLock 会检查 state 的值,如果为 0,通过 CAS 修改为 1,表示成功加锁。否则根据当前线程的公平性策略,加入到等待队列中。\n\n线程首次获取锁时,state 值设为 1;如果同一个线程再次获取锁时,state 加 1;每释放一次锁,state 减 1。\n\n当线程调用 `unlock()` 方法时,ReentrantLock 会将持有锁的 state 减 1,如果 `state = 0`,则释放锁,并唤醒等待队列中的线程来竞争锁。\n\n使用方式非常简单:\n\n\n```java\nclass CounterWithLock {\n private int count = 0;\n private final Lock lock = new ReentrantLock();\n\n public void increment() {\n lock.lock(); // 获取锁\n try {\n count++;\n } finally {\n lock.unlock(); // 释放锁\n }\n }\n\n public int getCount() {\n return count;\n }\n}\n```\n\n\n`new ReentrantLock()` 默认创建的是非公平锁 NonfairSync。在非公平锁模式下,锁可能会授予刚刚请求它的线程,而不考虑等待时间。当切换到公平锁模式下,锁会授予等待时间最长的线程。"
+ },
+ {
+ "id": 119,
+ "question": "ReentrantLock 怎么创建公平锁?",
+ "answer": "很简单,创建 ReentrantLock 的时候,传递参数 true 就可以了。\n\n\n```java\nReentrantLock lock = new ReentrantLock(true);\n// true 代表公平锁,false 代表非公平锁\npublic ReentrantLock(boolean fair) {\n sync = fair ? new FairSync() : new NonfairSync();\n}\n```\n\n\n#### [怎么创建一个非公平锁呢?](#怎么创建一个非公平锁呢)\n\n创建 ReentrantLock 时,不传递参数或者传递参数就好了。\n\n#### [非公平锁和公平锁有什么不同?](#非公平锁和公平锁有什么不同)\n\n两句话回答:\n\n公平锁意味着在多个线程竞争锁时,获取锁的顺序与线程请求锁的顺序相同,即先来先服务。\n\n非公平锁不保证线程获取锁的顺序,当锁被释放时,任何请求锁的线程都有机会获取锁,而不是按照请求的顺序。\n\n#### [公平锁的实现逻辑了解吗?](#公平锁的实现逻辑了解吗)\n\n公平锁的核心逻辑在 AQS 的 `hasQueuedPredecessors()` 方法中,该方法用于判断当前线程前面是否有等待的线程。\n\n\n\n如果队列前面有等待线程,当前线程就不能抢占锁,必须按照队列顺序排队。如果队列前面没有线程,或者当前线程是队列头部的线程,就可以获取锁。"
+ },
+ {
+ "id": 120,
+ "question": "CAS 了解多少?",
+ "answer": "CAS 是一种乐观锁,用于比较一个变量的当前值是否等于预期值,如果相等,则更新值,否则重试。\n\n\n\n在 CAS 中,有三个值:\n\n* V:要更新的变量(var)\n* E:预期值(expected)\n* N:新值(new)\n\n先判断 V 是否等于 E,如果等于,将 V 的值设置为 N;如果不等,说明已经有其它线程更新了 V,当前线程就放弃更新。\n\n这个比较和替换的操作需要是原子的,不可中断的。Java 中的 CAS 是由 Unsafe 类实现的。\n\nAtomicInteger 类的 compareAndSet 就是一个 CAS 方法:\n\n\n```java\nAtomicInteger atomicInteger = new AtomicInteger(0);\nint expect = 0;\nint update = 1;\natomicInteger.compareAndSet(expect, update);\n```\n\n\n它调用的是 Unsafe 的 compareAndSwapInt。\n\n\n\n#### [怎么保证 CAS 的原子性?](#怎么保证-cas-的原子性)\n\nCPU 会发出一个 LOCK 指令进行总线锁定,阻止其他处理器对内存地址进行操作,直到当前指令执行完成。\n\n\n```text\nlock cmpxchg [esi], eax ; 比较 esi 地址中的值与 eax,如果相等则替换\n```\n"
+ },
+ {
+ "id": 121,
+ "question": "CAS 有什么问题?",
+ "answer": "CAS 存在三个经典问题,ABA 问题、自旋开销大、只能操作一个变量等。\n\n\n\n#### [什么是 ABA 问题?](#什么是-aba-问题)\n\nABA 问题指的是,一个值原来是 A,后来被改为 B,再后来又被改回 A,这时 CAS 会误认为这个值没有发生变化。\n\n\n```text\n线程 1:CAS(A → B),修改变量 A → B\n线程 2:CAS(B → A),变量又变回 A\n线程 3:CAS(A → C),CAS 成功,但实际数据已被修改过!\n```\n\n\n可以使用版本号/时间戳的方式来解决 ABA 问题。\n\n比如说,每次变量更新时,不仅更新变量的值,还更新一个版本号。CAS 操作时,不仅比较变量的值,还比较版本号。\n\n\n```java\nclass OptimisticLockExample {\n private int version;\n private int value;\n\n public synchronized boolean updateValue(int newValue, int currentVersion) {\n if (this.version == currentVersion) {\n this.value = newValue;\n this.version++;\n return true;\n }\n return false;\n }\n}\n```\n\n\nJava 的 AtomicStampedReference 就增加了版本号,它会同时检查引用值和 stamp 是否都相等。\n\n\n\n使用示例:\n\n\n```java\nclass ABAFix {\n private static AtomicStampedReference ref = new AtomicStampedReference<>(\"100\", 1);\n\n public static void main(String[] args) {\n new Thread(() -> {\n int stamp = ref.getStamp();\n ref.compareAndSet(\"100\", \"200\", stamp, stamp + 1);\n ref.compareAndSet(\"200\", \"100\", ref.getStamp(), ref.getStamp() + 1);\n }).start();\n\n new Thread(() -> {\n try { Thread.sleep(100); } catch (InterruptedException e) {}\n int stamp = ref.getStamp();\n System.out.println(\"CAS 结果:\" + ref.compareAndSet(\"100\", \"300\", stamp, stamp + 1));\n }).start();\n }\n}\n```\n\n\n#### [自旋开销大怎么解决?](#自旋开销大怎么解决)\n\nCAS 失败时会不断自旋重试,如果一直不成功,会给 CPU 带来非常大的执行开销。\n\n可以加一个自旋次数的限制,超过一定次数,就切换到 synchronized 挂起线程。\n\n\n```java\nint MAX_RETRIES = 10;\nint retries = 0;\nwhile (!atomicInt.compareAndSet(expect, update)) {\n retries++;\n if (retries > MAX_RETRIES) {\n synchronized (this) { // 超过次数,使用 synchronized 处理\n if (atomicInt.get() == expect) {\n atomicInt.set(update);\n }\n }\n break;\n }\n}\n```\n\n\n#### [涉及到多个变量同时更新怎么办?](#涉及到多个变量同时更新怎么办)\n\n可以将多个变量封装为一个对象,使用 AtomicReference 进行 CAS 更新。\n\n\n```java\nclass Account {\n static class Balance {\n final int money;\n final int points;\n\n Balance(int money, int points) {\n this.money = money;\n this.points = points;\n }\n }\n\n private AtomicReference balance = new AtomicReference<>(new Balance(100, 10));\n\n public void update(int newMoney, int newPoints) {\n Balance oldBalance, newBalance;\n do {\n oldBalance = balance.get();\n newBalance = new Balance(newMoney, newPoints);\n } while (!balance.compareAndSet(oldBalance, newBalance));\n }\n}\n```"
+ },
+ {
+ "id": 122,
+ "question": "Java 有哪些保证原子性的方法?",
+ "answer": "\n\n比如说以 Atomic 开头的原子类,synchronized 关键字,ReentrantLock 锁等。"
+ },
+ {
+ "id": 123,
+ "question": "原子操作类了解多少?",
+ "answer": "原子操作类是基于 CAS + volatile 实现的,底层依赖于 Unsafe 类,最常用的有 AtomicInteger、AtomicLong、AtomicReference 等。\n\n\n\n像 AtomicIntegerArray 这种以 Array 结尾的,还可以原子更新数组里的元素。\n\n\n```java\nclass AtomicArrayExample {\n public static void main(String[] args) {\n AtomicIntegerArray atomicArray = new AtomicIntegerArray(new int[]{1, 2, 3});\n\n atomicArray.incrementAndGet(1); // 对索引 1 进行自增\n System.out.println(atomicArray.get(1)); // 输出 3\n }\n}\n```\n\n\n像 AtomicStampedReference 还可以通过版本号的方式解决 CAS 中的 ABA 问题。\n\n\n```java\nclass AtomicStampedReferenceExample {\n public static void main(String[] args) {\n AtomicStampedReference ref = new AtomicStampedReference<>(100, 1);\n\n int stamp = ref.getStamp(); // 获取版本号\n ref.compareAndSet(100, 200, stamp, stamp + 1); // A → B\n ref.compareAndSet(200, 100, ref.getStamp(), ref.getStamp() + 1); // B → A\n }\n}\n```"
+ },
+ {
+ "id": 124,
+ "question": "AtomicInteger 的源码读过吗?",
+ "answer": "有读过。\n\nAtomicInteger 是基于 volatile 和 CAS 实现的,底层依赖于 Unsafe 类。核心方法包括 getAndIncrement、compareAndSet 等。\n\n\n```java\npublic final int getAndIncrement() {\n return unsafe.getAndAddInt(this, valueOffset, 1);\n}\n```"
+ },
+ {
+ "id": 125,
+ "question": "线程死锁了解吗?",
+ "answer": "死锁发生在多个线程相互等待对方释放锁时。比如说线程 1 持有锁 R1,等待锁 R2;线程 2 持有锁 R2,等待锁 R1。\n\n\n\n#### [死锁发生的四个条件了解吗?](#死锁发生的四个条件了解吗)\n\n\n\n第一条件是**互斥**:资源不能被多个线程共享,一次只能由一个线程使用。如果一个线程已经占用了一个资源,其他请求该资源的线程必须等待,直到资源被释放。\n\n第二个条件是**持有并等待**:一个线程已经持有一个资源,并且在等待获取其他线程持有的资源。\n\n第三个条件是**不可抢占**:资源不能被强制从线程中夺走,必须等线程自己释放。\n\n第四个条件是**循环等待**:存在一种线程等待链,线程 A 等待线程 B 持有的资源,线程 B 等待线程 C 持有的资源,直到线程 N 又等待线程 A 持有的资源。\n\n#### [该如何避免死锁呢?](#该如何避免死锁呢)\n\n第一,所有线程都按照固定的顺序来申请资源。例如,先申请 R1 再申请 R2。\n\n第二,如果线程发现无法获取某个资源,可以先释放已经持有的资源,重新尝试申请。"
+ },
+ {
+ "id": 126,
+ "question": "死锁问题怎么排查呢?",
+ "answer": "首先从系统级别上排查,比如说在 Linux 生产环境中,可以先使用 `top` `ps` 等命令查看进程状态,看看是否有进程占用了过多的资源。\n\n接着,使用 JDK 自带的一些性能监控工具进行排查,比如说 使用 `jps -l` 查看当前进程,然后使用 `jstack 进程号` 查看当前进程的线程堆栈信息,看看是否有线程在等待锁资源。\n\n也可以使用一些可视化的性能监控工具,比如说 JConsole、VisualVM 等,查看线程的运行状态、锁的竞争情况等。\n\n\n\n我们来通过实际代码说明一下:\n\n\n```java\nclass DeadLockDemo {\n private static final Object lock1 = new Object();\n private static final Object lock2 = new Object();\n\n public static void main(String[] args) {\n new Thread(() -> {\n synchronized (lock1) {\n System.out.println(\"线程1获取到了锁1\");\n try {\n Thread.sleep(1000);\n } catch (InterruptedException e) {\n e.printStackTrace();\n }\n synchronized (lock2) {\n System.out.println(\"线程1获取到了锁2\");\n }\n }\n }).start();\n\n new Thread(() -> {\n synchronized (lock2) {\n System.out.println(\"线程2获取到了锁2\");\n try {\n Thread.sleep(1000);\n } catch (InterruptedException e) {\n e.printStackTrace();\n }\n synchronized (lock1) {\n System.out.println(\"线程2获取到了锁1\");\n }\n }\n }).start();\n }\n}\n```\n\n\n创建两个线程,每个线程都试图按照不同的顺序获取两个。\n\n锁的获取顺序不一致很容易导致死锁。运行这段代码,会发现两个线程都无法继续执行,进入了死锁状态。\n\n\n\n运行 `jstack pid` 命令,可以看到死锁的线程信息。\n\n\n\n编码时,尽量使用 `tryLock()` 代替 `lock()`,`tryLock()` 可以设置超时时间,避免线程一直等待。\n\n同时,尽量避免一个线程同时获取多个锁,如果需要多个锁,可以按照固定的顺序获取。"
+ },
+ {
+ "id": 127,
+ "question": "聊聊线程同步和互斥?(补充)",
+ "answer": "同步,意味着线程之间要密切合作,按照一定的顺序来执行任务。比如说,线程 A 先执行,线程 B 再执行。\n\n互斥,意味着线程之间要抢占资源,同一时间只能有一个线程访问共享资源。比如说,线程 A 在访问共享资源时,线程 B 不能访问。\n\n同步关注的是线程之间的协作,互斥关注的是线程之间的竞争。\n\n#### [如何实现同步和互斥?](#如何实现同步和互斥)\n\n可以使用 synchronized 关键字或者 Lock 接口的实现类,如 ReentrantLock 来给资源加锁。\n\n锁在操作系统层面的意思是 Mutex,某个线程进入临界区后,也就是获取到锁后,其他线程不能再进入临界区,要阻塞等待持有锁的线程离开临界区。\n\n\n\n#### [锁要解决哪些问题?](#锁要解决哪些问题)\n\n第一,谁可以拿到锁,可以是类对象,可以是当前的 this 对象,也可以是任何其他新建的对象。\n\n\n```java\nsynchronized (this) {\n // 临界区\n}\n```\n\n\n第二,抢占锁的规则,能不能抢占多次,自己能不能反复抢。\n\n第三,抢不到怎么办,自旋?阻塞?或者超时放弃?\n\n第四,锁被释放了还在等待锁的线程怎么办?是通知所有线程一起抢或者只告诉一个线程抢?\n\n#### [说说自旋锁?](#说说自旋锁)\n\n自旋锁是指当线程尝试获取锁时,如果锁已经被占用,线程不会立即阻塞,而是**通过自旋**,也就是循环等待的方式不断尝试获取锁。\n\n\n```text\n线程1 线程2\n | |\n | 获取锁成功 | 尝试获取锁\n |------------>|(锁已被占用,自旋等待)\n | 释放锁 |\n |<------------| 获取锁成功\n | |\n```\n\n\n适用于锁持有时间短的场景,ReentrantLock 的 tryLock 方法就用到了自旋锁。\n\n\n\n自旋锁的优点是可以避免线程切换带来的开销,缺点是如果锁被占用时间过长,会导致线程空转,浪费 CPU 资源。\n\n\n```java\nclass SpinLock {\n private AtomicBoolean lock = new AtomicBoolean(false);\n\n public void lock() {\n while (!lock.compareAndSet(false, true)) {\n // 自旋等待,不断尝试获取锁\n }\n }\n\n public void unlock() {\n lock.set(false);\n }\n\n public static void main(String[] args) {\n SpinLock spinLock = new SpinLock();\n\n Runnable task = () -> {\n spinLock.lock();\n try {\n System.out.println(Thread.currentThread().getName() + \" 获取到锁\");\n } finally {\n spinLock.unlock();\n }\n };\n\n Thread t1 = new Thread(task);\n Thread t2 = new Thread(task);\n\n t1.start();\n t2.start();\n }\n}\n```\n\n\n默认情况下,自旋锁会一直等待,直到获取到锁为止。在实际开发中,需要设置自旋次数或者超时时间。如果超过阈值,线程可以放弃锁或者进入阻塞状态。\n\n#### [互斥和同步在时间上有要求吗?](#互斥和同步在时间上有要求吗)\n\n有。\n\n互斥的核心是保证同一时刻只有一个线程能访问共享资源。\n\n同步强调的是线程之间的执行顺序,特别是在多个线程需要依赖于彼此的执行结果时。\n\n例如,在 CountDownLatch 中,主线程会等待多个子线程的任务完成。\n\n\n```java\nclass SyncExample {\n public static void main(String[] args) throws InterruptedException {\n CountDownLatch latch = new CountDownLatch(3);\n \n // 创建3个子线程\n for (int i = 0; i < 3; i++) {\n new Thread(() -> {\n try {\n Thread.sleep(1000); // 模拟任务\n System.out.println(\"打完练习伴侣者了.\");\n } catch (InterruptedException e) {\n e.printStackTrace();\n } finally {\n latch.countDown(); // 每个线程任务完成后计数器减1\n }\n }).start();\n }\n \n System.out.println(\"等打完三把练习伴侣者就去睡觉...\");\n latch.await(); // 主线程等待子线程完成\n System.out.println(\"好,练习伴侣者玩完了,可以睡了\");\n }\n}\n```\n\n\n所有子线程完成后,主线程才会继续执行。\n\n"
+ },
+ {
+ "id": 128,
+ "question": "聊聊悲观锁和乐观锁?(补充)",
+ "answer": "> 2024 年 05 月 01 日增补\n\n好的。\n\n悲观锁认为每次访问共享资源时都会发生冲突,所在在操作前一定要先加锁,防止其他线程修改数据。\n\n乐观锁认为冲突不会总是发生,所以在操作前不加锁,而是在更新数据时检查是否有其他线程修改了数据。如果发现数据被修改了,就会重试。\n\n#### [乐观锁发现有线程过来修改数据,怎么办?](#乐观锁发现有线程过来修改数据-怎么办)\n\n可以重新读取数据,然后再尝试更新,直到成功为止或达到最大重试次数。\n\n\n```text\n读取数据 -> 尝试更新 -> 成功(返回成功)\n |\n -> 失败 -> 重试 -> 达到最大次数 -> 返回失败\n```\n\n\n写个代码演示一下:\n\n\n```java\nclass CasRetryExample {\n private static AtomicInteger counter = new AtomicInteger(0);\n private static final int MAX_RETRIES = 5;\n\n public static void main(String[] args) {\n boolean success = false;\n int retries = 0;\n\n while (retries < MAX_RETRIES) {\n int currentValue = counter.get();\n boolean updated = counter.compareAndSet(currentValue, currentValue + 1);\n \n if (updated) {\n System.out.println(\"更新成功,当前值: \" + counter.get());\n success = true;\n break;\n } else {\n retries++;\n System.out.println(\"更新失败,进行第 \" + retries + \" 次重试\");\n }\n }\n\n if (!success) {\n System.out.println(\"达到最大重试次数,操作失败\");\n }\n }\n}\n```"
+ }
+ ]
+ },
+ {
+ "id": 23,
+ "categoryName": "并发工具类",
+ "questions": [
+ {
+ "id": 129,
+ "question": "CountDownLatch 了解吗?",
+ "answer": "CountDownLatch 是 JUC 中的一个同步工具类,用于协调多个线程之间的同步,确保主线程在多个子线程完成任务后继续执行。\n\n它的核心思想是通过一个倒计时计数器来控制多个线程的执行顺序。\n\n\n```java\nclass CountDownLatchExample {\n public static void main(String[] args) throws InterruptedException {\n int threadCount = 3;\n CountDownLatch latch = new CountDownLatch(threadCount);\n\n for (int i = 0; i < threadCount; i++) {\n new Thread(() -> {\n try {\n Thread.sleep((long) (Math.random() * 1000)); // 模拟任务执行\n System.out.println(Thread.currentThread().getName() + \" 执行完毕\");\n } catch (InterruptedException e) {\n e.printStackTrace();\n } finally {\n latch.countDown(); // 线程完成后,计数器 -1\n }\n }).start();\n }\n\n latch.await(); // 主线程等待\n System.out.println(\"所有子线程执行完毕,主线程继续执行\");\n }\n}\n```\n\n\n在使用的时候,我们需要先初始化一个 CountDownLatch 对象,指定一个计数器的初始值,表示需要等待的线程数量。\n\n然后在每个子线程执行完任务后,调用 `countDown()` 方法,计数器减 1。\n\n接着主线程调用 `await()` 方法进入阻塞状态,直到计数器为 0,也就是所有子线程都执行完任务后,主线程才会继续执行。\n\n\n\n以练习伴侣者荣耀为例,我们来创建五个线程,分别代表大乔、兰陵王、安其拉、哪吒和铠。每个玩家都调用 `countDown()` 方法,表示已就位。主线程调用 `await()` 方法,等待所有玩家就位。\n\n\n```java\npublic static void main(String[] args) throws InterruptedException {\n CountDownLatch countDownLatch = new CountDownLatch(5);\n\n Thread daqiao = new Thread(() -> {\n System.out.println(\"大乔已就位!\");\n countDownLatch.countDown();\n });\n Thread lanlingwang = new Thread(() -> {\n System.out.println(\"兰陵练习伴侣已就位!\");\n countDownLatch.countDown();\n });\n Thread anqila = new Thread(() -> {\n System.out.println(\"安其拉已就位!\");\n countDownLatch.countDown();\n });\n Thread nezha = new Thread(() -> {\n System.out.println(\"哪吒已就位!\");\n countDownLatch.countDown();\n });\n Thread kai = new Thread(() -> {\n System.out.println(\"铠已就位!\");\n countDownLatch.countDown();\n });\n\n daqiao.start();\n lanlingwang.start();\n anqila.start();\n nezha.start();\n kai.start();\n\n countDownLatch.await();\n System.out.println(\"全员就位,开始游戏!\");\n}\n```\n\n\n五个玩家在倒计时结束后,一起出击。\n\n\n```java\nprivate static void waitToFight(CountDownLatch countDownLatch, String name) {\n try {\n countDownLatch.await(); // 在此等待信号再继续\n System.out.println(name + \" 收到,发起进攻!\");\n } catch (InterruptedException e) {\n Thread.currentThread().interrupt();\n System.out.println(name + \" 被中断\");\n }\n}\n\npublic static void main(String[] args) {\n CountDownLatch countDownLatch = new CountDownLatch(1);\n\n Thread daqiao = new Thread(() -> waitToFight(countDownLatch, \"大乔\"), \"Thread-大乔\");\n Thread lanlingwang = new Thread(() -> waitToFight(countDownLatch, \"兰陵练习伴侣\"), \"Thread-兰陵练习伴侣\");\n Thread anqila = new Thread(() -> waitToFight(countDownLatch, \"安琪拉\"), \"Thread-安琪拉\");\n Thread nezha = new Thread(() -> waitToFight(countDownLatch, \"哪吒\"), \"Thread-哪吒\");\n Thread kai = new Thread(() -> waitToFight(countDownLatch, \"凯\"), \"Thread-凯\");\n\n daqiao.start();\n lanlingwang.start();\n anqila.start();\n nezha.start();\n kai.start();\n\n try {\n Thread.sleep(5000); // 模拟准备时间\n } catch (InterruptedException e) {\n Thread.currentThread().interrupt();\n System.out.println(\"主线程被中断\");\n }\n\n System.out.println(\"敌军还有 5 秒到达战场,全军出击!\");\n countDownLatch.countDown(); // 发出信号\n}\n```\n\n\n#### [场景题:假如要查10万多条数据,用线程池分成20个线程去执行,怎么做到等所有的线程都查找完之后,即最后一条结果查找结束了,才输出结果?](#场景题-假如要查10万多条数据-用线程池分成20个线程去执行-怎么做到等所有的线程都查找完之后-即最后一条结果查找结束了-才输出结果)\n\n很简单,可以使用 CountDownLatch 来实现。CountDownLatch 非常适合这个场景。\n\n第一步,创建 CountDownLatch 对象,初始值设定为 20,表示 20 个线程需要完成任务。\n\n第二步,创建线程池,每个线程执行查询操作,查询完毕后调用 `countDown()` 方法,计数器减 1。\n\n第三步,主线程调用 `await()` 方法,等待所有线程执行完毕。\n\n\n```java\nclass DataQueryExample {\n\n public static void main(String[] args) throws InterruptedException {\n // 模拟10万条数据\n int totalRecords = 100000;\n int threadCount = 20;\n int batchSize = totalRecords / threadCount; // 每个线程处理的数据量\n\n // 创建线程池\n ExecutorService executor = Executors.newFixedThreadPool(threadCount);\n CountDownLatch latch = new CountDownLatch(threadCount);\n\n // 模拟查询结果\n ConcurrentLinkedQueue results = new ConcurrentLinkedQueue<>();\n\n for (int i = 0; i < threadCount; i++) {\n int start = i * batchSize;\n int end = (i == threadCount - 1) ? totalRecords : (start + batchSize);\n \n executor.execute(() -> {\n try {\n // 模拟查询操作\n for (int j = start; j < end; j++) {\n results.add(\"Data-\" + j);\n }\n System.out.println(Thread.currentThread().getName() + \" 处理数据 \" + start + \" - \" + end);\n } finally {\n latch.countDown(); // 线程任务完成,计数器减1\n }\n });\n }\n\n // 等待所有线程完成\n latch.await();\n executor.shutdown();\n\n // 输出结果\n System.out.println(\"所有线程执行完毕,查询结果总数:\" + results.size());\n }\n}\n```"
+ },
+ {
+ "id": 130,
+ "question": "CyclicBarrier 了解吗?",
+ "answer": "了解。\n\nCyclicBarrier 的字面意思是可循环使用的屏障,用于多个线程相互等待,直到所有线程都到达屏障后再同时执行。\n\n\n\n在使用的时候,我们需要先初始化一个 CyclicBarrier 对象,指定一个屏障值 N,表示需要等待的线程数量。\n\n然后每个线程执行 `await()` 方法,表示自己已经到达屏障,等待其他线程,此时屏障值会减 1。\n\n当所有线程都到达屏障后,也就是屏障值为 0 时,所有线程会继续执行。\n\n\n```java\nclass CyclicBarrierExample {\n private static final int THREAD_COUNT = 3;\n private static final CyclicBarrier barrier = new CyclicBarrier(THREAD_COUNT);\n\n public static void main(String[] args) {\n for (int i = 0; i < THREAD_COUNT; i++) {\n new Thread(() -> {\n try {\n System.out.println(Thread.currentThread().getName() + \" 到达屏障\");\n barrier.await(); // 线程阻塞,直到所有线程都到达\n System.out.println(Thread.currentThread().getName() + \" 继续执行\");\n } catch (InterruptedException | BrokenBarrierException e) {\n e.printStackTrace();\n }\n }).start();\n }\n }\n}\n```"
+ },
+ {
+ "id": 131,
+ "question": "CyclicBarrier 和 CountDownLatch 有什么区别?",
+ "answer": "CyclicBarrier 让所有线程相互等待,全部到达后再继续;CountDownLatch 让主线程等待所有子线程执行完再继续。\n\n| 对比项 | CyclicBarrier | CountDownLatch |\n| --- | --- | --- |\n| 主要用途 | 让所有线程相互等待,全部到达后再继续 | 让主线程等待所有子线程执行完 |\n| 可重用性 | ✅ 可重复使用,每次屏障打开后自动重置 | ❌ 不可重复使用,计数器归零后不能恢复 |\n| 是否可执行回调 | ✅ 可以,所有线程到达屏障后可执行 barrierAction | ❌ 不能 |\n| 线程等待情况 | 所有线程互相等待,一个线程未到达,其他线程都会阻塞 | 主线程等待所有子线程完成,子线程执行完后可继续运行 |\n| 适用场景 | 线程相互依赖,需要同步执行 | 主线程等待子线程完成 |\n| 示例场景 | 计算任务拆分,所有线程都到达后才能继续 | 主线程等多个任务初始化完成 |"
+ },
+ {
+ "id": 132,
+ "question": "Semaphore 了解吗?",
+ "answer": "Semaphore——信号量,用于控制同时访问某个资源的线程数量,类似限流器,确保最多只有指定数量的线程能够访问某个资源,超过的必须等待。\n\n\n\n拿停车场来举例。\n\n停车场的车位是有限的,如果有空位,显示牌需要显示剩余的车位,车辆就可以驶入;否则就会显示数字 0,新来的车辆就得排队等待。\n\n如果有车离开,显示牌重新显示闲置的车位数量,等待的车辆按序驶入停车场。\n\n\n\n在使用 Semaphore 时,首先需要初始化一个 Semaphore 对象,指定许可证数量,表示最多允许多少个线程同时访问资源。\n\n然后在每个线程访问资源前,调用 `acquire()` 方法获取许可证,如果没有可用许可证,则阻塞等待。\n\n需要注意的是,访问完资源后,要调用 `release()` 方法释放许可证。\n\n\n```java\nclass SemaphoreExample {\n private static final int THREAD_COUNT = 5;\n private static final Semaphore semaphore = new Semaphore(2); // 最多允许 2 个线程访问\n\n public static void main(String[] args) {\n for (int i = 0; i < THREAD_COUNT; i++) {\n new Thread(() -> {\n try {\n semaphore.acquire(); // 获取许可(如果没有可用许可,则阻塞)\n System.out.println(Thread.currentThread().getName() + \" 访问资源...\");\n Thread.sleep(2000); // 模拟任务执行\n } catch (InterruptedException e) {\n e.printStackTrace();\n } finally {\n semaphore.release(); // 释放许可\n }\n }).start();\n }\n }\n}\n```\n\n\nSemaphore 可以用于流量控制,比如数据库连接池、网络连接池等。\n\n假如有这样一个需求,要读取几万个文件的数据,因为都是 IO 密集型任务,我们可以启动几十个线程并发地读取。\n\n但是在读到内存后,需要存储到数据库,而数据库连接数是有限的,比如说只有 10 个,那我们就必须控制线程的数量,保证同时只有 10 个线程在使用数据库连接。\n\n这个时候,就可以使用 Semaphore 来做流量控制:\n\n\n```java\nclass SemaphoreTest {\n private static final int THREAD_COUNT = 30;\n private static ExecutorService threadPool = Executors.newFixedThreadPool(THREAD_COUNT);\n private static Semaphore s = new Semaphore(10);\n\n public static void main(String[] args) {\n for (int i = 0; i < THREAD_COUNT; i++) {\n threadPool.execute(new Runnable() {\n @Override\n public void run() {\n try {\n s.acquire();\n System.out.println(\"save data\");\n s.release();\n } catch (InterruptedException e) {\n }\n }\n });\n }\n threadPool.shutdown();\n }\n}\n```"
+ },
+ {
+ "id": 133,
+ "question": "Exchanger 了解吗?",
+ "answer": "Exchanger——交换者,用于在两个线程之间进行数据交换。\n\n\n\n支持双向数据交换,比如说线程 A 调用 `exchange(dataA)`,线程 B 调用 `exchange(dataB)`,它们会在同步点交换数据,即 A 得到 B 的数据,B 得到 A 的数据。\n\n如果一个线程先调用 `exchange()`,它会阻塞等待,直到另一个线程也调用 `exchange()`。\n\n使用 Exchanger 的时候,需要先创建一个 Exchanger 对象,然后在两个线程中调用 `exchange()` 方法,就可以进行数据交换了。\n\n\n```java\nclass ExchangerExample {\n private static final Exchanger exchanger = new Exchanger<>();\n\n public static void main(String[] args) {\n new Thread(() -> {\n try {\n String threadAData = \"数据 A\";\n System.out.println(\"线程 A 交换前的数据:\" + threadAData);\n String received = exchanger.exchange(threadAData);\n System.out.println(\"线程 A 收到的数据:\" + received);\n } catch (InterruptedException e) {\n e.printStackTrace();\n }\n }).start();\n\n new Thread(() -> {\n try {\n String threadBData = \"数据 B\";\n System.out.println(\"线程 B 交换前的数据:\" + threadBData);\n String received = exchanger.exchange(threadBData);\n System.out.println(\"线程 B 收到的数据:\" + received);\n } catch (InterruptedException e) {\n e.printStackTrace();\n }\n }).start();\n }\n}\n```\n\n\nExchanger 可以用于遗传算法,也可以用于校对工作,比如我们将纸制银行流水通过人工的方式录入到电子银行时,为了避免错误,可以录入两遍,然后通过 Exchanger 来校对两次录入的结果。\n\n\n```java\nclass ExchangerTest {\n private static final Exchanger exgr = new Exchanger();\n private static ExecutorService threadPool = Executors.newFixedThreadPool(2);\n\n public static void main(String[] args) {\n threadPool.execute(new Runnable() {\n @Override\n public void run() {\n try {\n String A = \"银行流水A\"; // A录入银行流水数据\n exgr.exchange(A);\n } catch (InterruptedException e) {\n }\n }\n });\n threadPool.execute(new Runnable() {\n @Override\n public void run() {\n try {\n String B = \"银行流水B\"; // B录入银行流水数据\n String A = exgr.exchange(\"B\");\n System.out.println(\"A和B数据是否一致:\" + A.equals(B) + \",A录入的是:\"\n + A + \",B录入是:\" + B);\n } catch (InterruptedException e) {\n }\n }\n });\n threadPool.shutdown();\n }\n}\n```"
+ },
+ {
+ "id": 134,
+ "question": "能说一下 ConcurrentHashMap 的实现吗?(补充)",
+ "answer": "> 2024 年 03 月 25 日增补,从集合框架篇移到这里。\n\n好的。 是 HashMap 的线程安全版本。\n\nJDK 7 采用的是分段锁,整个 Map 会被分为若干段,每个段都可以独立加锁。不同的线程可以同时操作不同的段,从而实现并发。\n\n\n\nJDK 8 使用了一种更加细粒度的锁——桶锁,再配合 CAS + synchronized 代码块控制并发写入,以最大程度减少锁的竞争。\n\n\n\n对于读操作,ConcurrentHashMap 使用了 volatile 变量来保证内存可见性。\n\n对于写操作,ConcurrentHashMap 优先使用 CAS 尝试插入,如果成功就直接返回;否则使用 synchronized 代码块进行加锁处理。\n\n#### [说一下 JDK 7 中 ConcurrentHashMap 的实现原理?](#说一下-jdk-7-中-concurrenthashmap-的实现原理)\n\n好的。\n\nJDK 7 的 ConcurrentHashMap 采用的是分段锁,整个 Map 会被分为若干段,每个段都可以独立加锁,每个段类似一个 Hashtable。\n\n\n\n每个段维护一个键值对数组 `HashEntry[] table`,HashEntry 是一个单项链表。\n\n\n```java\nstatic final class HashEntry {\n final int hash;\n final K key;\n volatile V value;\n final HashEntry next;\n}\n```\n\n\n段继承了 ReentrantLock,所以每个段都是一个可重入锁,不同的线程可以同时操作不同的段,从而实现并发。\n\n\n```java\nstatic final class Segment extends ReentrantLock {\n transient volatile HashEntry[] table;\n transient int count;\n}\n```\n\n\n#### [说一下 JDK 7 中 ConcurrentHashMap 的 put 流程?](#说一下-jdk-7-中-concurrenthashmap-的-put-流程)\n\nput 流程和 HashMap 非常类似,只不过是先定位到具体的段,再通过 ReentrantLock 去操作而已。一共可以分为 4 个步骤:\n\n第一步,计算 key 的 hash,定位到段,段如果是空就先初始化;\n\n第二步,使用 ReentrantLock 进行加锁,如果加锁失败就自旋,自旋超过次数就阻塞,保证一定能获取到锁;\n\n第三步,遍历段中的键值对 HashEntry,key 相同直接替换,key 不存在就插入。\n\n第四步,释放锁。\n\n\n\n#### [说一下 JDK 7 中 ConcurrentHashMap 的 get 流程?](#说一下-jdk-7-中-concurrenthashmap-的-get-流程)\n\nget 就更简单了,先计算 key 的 hash 找到段,再遍历段中的键值对,找到就直接返回 value。\n\nget 不用加锁,因为是 value 是 的,所以线程读取 value 时不会出现可见性问题。\n\n#### [说一下 JDK 8 中 ConcurrentHashMap 的实现原理?](#说一下-jdk-8-中-concurrenthashmap-的实现原理)\n\n好的。\n\nJDK 8 中的 ConcurrentHashMap 取消了分段锁,采用 CAS + synchronized 来实现更细粒度的桶锁,并且使用红黑树来优化链表以提高哈希冲突时的查询效率,性能比 JDK 7 有了很大的提升。\n\n#### [说一下 JDK 8 中 ConcurrentHashMap 的 put 流程?](#说一下-jdk-8-中-concurrenthashmap-的-put-流程)\n\n\n\n第一步,计算 key 的 hash,以确定桶在数组中的位置。如果数组为空,采用 CAS 的方式初始化,以确保只有一个线程在初始化数组。\n\n\n```java\n// 计算 hash\nint hash = spread(key.hashCode());\n\n// 初始化数组\nif (tab == null || (n = tab.length) == 0)\n tab = initTable();\n\n// 计算桶的位置\nint i = (n - 1) & hash;\n```\n\n\n第二步,如果桶为空,直接 CAS 插入节点。如果 CAS 操作失败,会退化为 synchronized 代码块来插入节点。\n\n\n```java\n// CAS 插入节点\nif (tabAt(tab, i) == null) {\n if (casTabAt(tab, i, null, new Node(hash, key, value, null)))\n break;\n}\n\n// 否则,使用 synchronized 代码块插入节点\nelse {\n synchronized (f) { // **只锁当前桶**\n if (tabAt(tab, i) == f) { // 确保未被其他线程修改\n if (f.hash >= 0) { // 链表处理\n for (Node e = f;;) {\n K ek;\n if (e.hash == hash && ((ek = e.key) == key || (key != null && key.equals(ek)))) {\n e.val = value;\n break;\n }\n e = e.next;\n }\n } else if (f instanceof TreeBin) { // **红黑树处理**\n ((TreeBin) f).putTreeVal(hash, key, value);\n }\n }\n }\n}\n```\n\n\n插入的过程中会判断桶的哈希是否小于 0(`f.hash >= 0`),小于 0 说明是红黑树,大于等于 0 说明是链表。\n\n这里补充一点:在 ConcurrentHashMap 的实现中,红黑树节点 TreeBin 的 hash 值固定为 -2。\n\n\n\n第三步,如果链表长度超过 8,转换为红黑树。\n\n\n```java\nif (binCount >= TREEIFY_THRESHOLD)\n treeifyBin(tab, i);\n```\n\n\n第四步,在插入新节点后,会调用 `addCount()` 方法检查是否需要扩容。\n\n\n```java\naddCount(1L, binCount);\n```\n\n\n#### [说一下 JDK 8 中 ConcurrentHashMap 的 get 流程?](#说一下-jdk-8-中-concurrenthashmap-的-get-流程)\n\nget 也是通过 key 的 hash 进行定位,如果该位置节点的哈希匹配且键相等,则直接返回值。\n\n\n\n如果节点的哈希为负数,说明是个特殊节点,比如说如树节点或者正在迁移的节点,就调用`find`方法查找。\n\n\n\n否则遍历链表查找匹配的键。如果都没找到,返回 null。\n\n#### [说一下 HashMap 和 ConcurrentHashMap 的区别?](#说一下-hashmap-和-concurrenthashmap-的区别)\n\nHashMap 是非线程安全的,多线程环境下应该使用 ConcurrentHashMap。\n\n#### [你项目中怎么使用 ConcurrentHashMap 的?](#你项目中怎么使用-concurrenthashmap-的)\n\n在中,很多地方都用到了 ConcurrentHashMap,比如说在异步工具类 AsyncUtil 中,就使用了 ConcurrentHashMap 来存储任务的名称和它们的运行时间,以便观察和分析任务的执行情况。\n\n\n\n#### [说一下 ConcurrentHashMap 对 HashMap 的改进?](#说一下-concurrenthashmap-对-hashmap-的改进)\n\n首先是 hash 的计算方法上,ConcurrentHashMap 的 spread 方法接收一个已经计算好的 hashCode,然后将这个哈希码的高 16 位与自身进行异或运算。\n\n\n```java\nstatic final int spread(int h) {\n return (h ^ (h >>> 16)) & HASH_BITS;\n}\n```\n\n\n比 HashMap 的 hash 计算多了一个 `& HASH_BITS` 的操作。这里的 HASH\\_BITS 是一个常数,值为 0x7fffffff,它确保结果是一个非负整数。\n\n\n```java\nstatic final int hash(Object key) {\n int h;\n return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);\n}\n```\n\n\n另外,ConcurrentHashMap 对节点 Node 做了进一步的封装,比如说用 Forwarding Node 来表示正在进行扩容的节点。\n\n\n```java\nstatic final class ForwardingNode extends Node {\n final Node[] nextTable;\n ForwardingNode(Node[] tab) {\n super(MOVED, null, null, null);\n this.nextTable = tab;\n }\n}\n```\n\n\n最后就是 put 方法,通过 CAS + synchronized 代码块来进行并发写入。\n\n\n\n#### [为什么 ConcurrentHashMap 在 JDK 1.7 中要用 ReentrantLock,而在 JDK 1.8 要用 synchronized](#为什么-concurrenthashmap-在-jdk-1-7-中要用-reentrantlock-而在-jdk-1-8-要用-synchronized)\n\nJDK 1.7 中的 ConcurrentHashMap 使用了分段锁机制,每个 Segment 都继承了 ReentrantLock,这样可以保证每个 Segment 都可以独立地加锁。\n\n而在 JDK 1.8 中,ConcurrentHashMap 取消了 Segment 分段锁,采用了更加精细化的锁——桶锁,以及 CAS 无锁算法,每个桶都可以独立地加锁,只有在 CAS 失败时才会使用 synchronized 代码块加锁,这样可以减少锁的竞争,提高并发性能。"
+ },
+ {
+ "id": 135,
+ "question": "ConcurrentHashMap 怎么保证可见性?(补充)",
+ "answer": "> 2024 年 03 月 25 日增补\n\nConcurrentHashMap 中的 Node 节点中,value 和 next 都是 volatile 的,这样就可以保证对 value 或 next 的更新会被其他线程立即看到。\n\n\n```java\nstatic class Node implements Map.Entry {\n final int hash;\n final K key;\n volatile V value;\n volatile Node next;\n}\n```"
+ },
+ {
+ "id": 136,
+ "question": "为什么 ConcurrentHashMap 比 Hashtable 效率高(补充)",
+ "answer": "> 2024 年 03 月 26 日增补,从集合框架移动到并发编程这里\n\nHashtable 在任何时刻只允许一个线程访问整个 Map,是通过对整个 Map 加锁来实现线程安全的。比如 get 和 put 方法,是直接在方法上加的 synchronized 关键字。\n\n\n```java\npublic synchronized V put(K key, V value) {\n if (value == null) throw new NullPointerException();\n int hash = key.hashCode();\n int index = (hash & 0x7FFFFFFF) % table.length;\n ...\n return oldValue;\n}\n```\n\n\n而 ConcurrentHashMap 在 JDK 8 中是采用 CAS + synchronized 实现的,仅在必要时加锁。\n\n比如说 put 的时候优先使用 CAS 尝试插入,如果失败再使用 synchronized 代码块加锁。\n\nget 的时候是完全无锁的,因为 value 是 修饰的,保证了内存可见性。\n\n\n```java\npublic V get(Object key) {\n int hash = spread(key.hashCode());\n Node[] tab = table;\n int index = (tab.length - 1) & hash;\n Node e = tabAt(tab, index);\n \n if (e != null) {\n do {\n if (e.hash == hash && (e.key == key || (key != null && key.equals(e.key)))) {\n return e.value; // 读取 volatile 变量,保证可见性\n }\n } while ((e = e.next) != null);\n }\n return null;\n}\n```"
+ },
+ {
+ "id": 137,
+ "question": "能说一下 CopyOnWriteArrayList 的实现原理吗?(补充)",
+ "answer": "CopyOnWriteArrayList 是 ArrayList 的线程安全版本,适用于读多写少的场景。它的核心思想是写操作时创建一个新数组,修改后再替换原数组,这样就能够确保读操作无锁,从而提高并发性能。\n\n\n\n内部使用 volatile 变量来修饰数组 array,以读操作的内存可见性。\n\n\n```java\nprivate transient volatile Object[] array;\n```\n\n\n写操作的时候使用 ReentrantLock 来保证线程安全。\n\n\n```java\npublic boolean add(E e) {\n final ReentrantLock lock = this.lock;\n // 加锁\n lock.lock();\n try {\n Object[] elements = getArray();\n int len = elements.length;\n // 创建一个新数组\n Object[] newElements = Arrays.copyOf(elements, len + 1);\n newElements[len] = e;\n // 替换原数组\n setArray(newElements);\n return true;\n } finally {\n // 释放锁\n lock.unlock();\n }\n}\n```\n\n\n缺点就是写操作的时候会复制一个新数组,如果数组很大,写操作的性能会受到影响。"
+ },
+ {
+ "id": 138,
+ "question": "能说一下 BlockingQueue 吗?(补充)",
+ "answer": "> 2024 年 08 月 18 日增补,从集合框架移动到并发编程这里\n\n是 JUC 包下的一个线程安全队列,支持阻塞式的“生产者-消费者”模型。\n\n当队列容器已满,生产者线程会被阻塞,直到消费者线程取走元素后为止;当队列容器为空时,消费者线程会被阻塞,直至队列非空时为止。\n\nBlockingQueue 的实现类有很多,比如说 ArrayBlockingQueue、PriorityBlockingQueue 等。\n\n| 实现类 | 数据结构 | 是否有界 | 特点 |\n| --- | --- | --- | --- |\n| ArrayBlockingQueue | 数组 | ✅ 有界 | 基于数组,固定容量,FIFO |\n| LinkedBlockingQueue | 链表 | ✅ 可有界(默认 Integer.MAX\\_VALUE) | 基于链表,吞吐量比 ArrayBlockingQueue 高 |\n| PriorityBlockingQueue | 堆(优先队列) | ❌ 无界 | 元素按优先级排序(非 FIFO) |\n| DelayQueue | 优先队列(基于 Delayed 接口) | ❌ 无界 | 元素到期后才能被取出 |\n| SynchronousQueue | 无缓冲 | ✅ 容量为 0 | 必须一对一交换数据,适用于高吞吐的任务提交 |\n| LinkedTransferQueue | 链表 | ❌ 无界 | 支持 tryTransfer(),数据立即交给消费者 |\n\n#### [阻塞队列是如何实现的?](#阻塞队列是如何实现的)\n\n阻塞队列使用 + Condition 来确保并发安全。\n\n以 ArrayBlockingQueue 为例,它内部维护了一个数组,使用两个指针分别指向队头和队尾。\n\nput 的时候先用 ReentrantLock 加锁,然后判断队列是否已满,如果已满就阻塞等待,否则插入元素。\n\n\n```java\nfinal ReentrantLock lock;\nprivate final Condition notEmpty;\nprivate final Condition notFull;\n\npublic void put(E e) throws InterruptedException {\n final ReentrantLock lock = this.lock;\n lock.lockInterruptibly(); // 🔹 加锁,确保线程安全\n try {\n while (count == items.length) { // 🔹 队列满,阻塞\n notFull.await();\n }\n enqueue(e); // 🔹 插入元素\n } finally {\n lock.unlock(); // 🔹 释放锁\n }\n}\n```"
+ }
+ ]
+ },
+ {
+ "id": 24,
+ "categoryName": "线程池",
+ "questions": [
+ {
+ "id": 139,
+ "question": "什么是线程池?",
+ "answer": "线程池是用来管理和复用线程的工具,它可以减少线程的创建和销毁开销。\n\n\n\n在 Java 中,ThreadPoolExecutor 是线程池的核心实现,它通过核心线程数、最大线程数、任务队列和拒绝策略来控制线程的创建和执行。\n\n举个例子:就像你开了一家餐厅,线程池就相当于固定数量的服务员,顾客(任务)来了就安排空闲的服务员(线程)处理,避免了频繁招人和解雇的成本。"
+ },
+ {
+ "id": 140,
+ "question": "你在项目中有用到线程池吗?",
+ "answer": "有,用到过很多次。\n\n比如说在当中, 我们就封装了一个异步工具类 AsyncUtil,内置了可配置的线程池,基于 ThreadPoolExecutor,适用于 IO 密集型任务。\n\n其中 corePoolSize 为 CPU 核心数的两倍,因为技术派中的大多数任务都是 IO 密集型的,maxPoolSize 设置为 50,是一个比较理想的值,尤其是在本地环境中;阻塞队列为 SynchronousQueue,意味着任务被创建后可以直接提交给等待的线程处理。"
+ },
+ {
+ "id": 141,
+ "question": "说一下线程池的工作流程?",
+ "answer": "可以简单总结为:\n\n任务提交 → 核心线程执行 → 任务队列缓存 → 非核心线程执行 → 拒绝策略处理。\n\n第一步,线程池通过 `submit()` 提交任务。\n\n\n```java\nExecutorService threadPool = Executors.newFixedThreadPool(5);\nthreadPool.submit(() -> {\n System.out.println(Thread.currentThread().getName() + \"\\t\" + \"办理业务\");\n});\n```\n\n\n第二步,线程池会先创建核心线程来执行任务。\n\n\n```java\nif (workerCountOf(c) < corePoolSize) {\n if (addWorker(command, true)) {\n return;\n }\n}\n```\n\n\n第三步,如果核心线程都在忙,任务会被放入任务队列中。\n\n\n```java\nworkQueue.offer(task);\n```\n\n\n第四步,如果任务队列已满,且当前线程数量小于最大线程数,线程池会创建新的线程来处理任务。\n\n\n```java\nif (!addWorker(command, false))\n```\n\n\n第五步,如果线程池中的线程数量已经达到最大线程数,且任务队列已满,线程池会执行拒绝策略。\n\n\n```java\nhandler.rejectedExecution(command, this);\n```\n\n\n另外一版回答。\n\n第一步,创建线程池。\n\n第二步,调用线程池的 `execute()`方法,准备执行任务。\n\n* 如果正在运行的线程数量小于 corePoolSize,那么线程池会创建一个新的线程来执行这个任务;\n* 如果正在运行的线程数量大于或等于 corePoolSize,那么线程池会将这个任务放入等待队列;\n* 如果等待队列满了,而且正在运行的线程数量小于 maximumPoolSize,那么线程池会创建新的线程来执行这个任务;\n* 如果等待队列满了,而且正在运行的线程数量大于或等于 maximumPoolSize,那么线程池会执行拒绝策略。\n\n\n\n第三步,线程执行完毕后,线程并不会立即销毁,而是继续保持在池中等待下一个任务。\n\n第四步,当线程空闲时间超出指定时间,且当前线程数量大于核心线程数时,线程会被回收。\n\n#### [能用一个生活中的例子说明下吗?](#能用一个生活中的例子说明下吗)\n\n可以。有个名叫“你一定暴富”的银行,该银行有 6 个窗口,现在开放了 3 个窗口,坐着 3 个小姐姐在办理业务。\n\n靓仔小二去办理业务,会遇到什么情况呢?\n\n第一情况,小二发现有个空闲的小姐姐,正在翘首以盼,于是小二就快马加鞭跑过去办理了。\n\n\n\n第二种情况,小姐姐们都在忙,接待员小美招呼小二去排队区区取号排队,让小二稍安勿躁。\n\n\n\n第三种情况,不仅小姐姐们都在忙,排队区也满了,小二着急用钱,于是脾气就上来了,和接待员小美对线了起来,要求开放另外 3 个空闲的窗口。\n\n小美迫于小二的压力,开放了另外 3 个窗口,排队区的人立马就冲了过去。\n\n\n\n第四种情况,6 个窗口的小姐姐都在忙,排队区也满了。。。\n\n\n\n接待员小美给了小二 4 个选项:\n\n1. 对不起,我们暴富银行系统瘫痪了。\n2. 没看忙着呢,谁叫你来办的你找谁去!\n3. 靓仔,看你比较急,去队里偷偷加个塞。\n4. 不好意思,今天没办法,你改天再来吧。\n\n这个流程和线程池不能说一模一样,简直就是一模一样:\n\n1. corePoolSize 对应营业窗口数 3\n2. maximumPoolSize 对应最大窗口数 6\n3. workQueue 对应排队区\n4. handler 对应接待员小美\n\n\n```java\nclass ThreadPoolDemo {\n public static void main(String[] args) {\n // 创建一个线程池\n ExecutorService threadPool = new ThreadPoolExecutor(\n 3, // 核心线程数\n 6, // 最大线程数\n 0, // 线程空闲时间\n TimeUnit.SECONDS, // 时间单位\n new LinkedBlockingQueue<>(10), // 等待队列\n Executors.defaultThreadFactory(), // 线程工厂\n new ThreadPoolExecutor.AbortPolicy() // 拒绝策略\n );\n // 模拟 10 个顾客来银行办理业务\n try {\n for (int i = 1; i <= 10; i++) {\n final int tempInt = i;\n threadPool.execute(() -> {\n System.out.println(Thread.currentThread().getName() + \"\\t\" + \"办理业务\" + tempInt);\n });\n }\n } catch (Exception e) {\n e.printStackTrace();\n } finally {\n threadPool.shutdown();\n }\n }\n}\n```"
+ },
+ {
+ "id": 142,
+ "question": "线程池的主要参数有哪些?",
+ "answer": "线程池有 7 个参数,需要重点关注的有核心线程数、最大线程数、等待队列、拒绝策略。\n\n\n\n**①、corePoolSize**:核心线程数,长期存活,执行任务的主力。\n\n**②、maximumPoolSize**:线程池允许的最大线程数。\n\n**③、workQueue**:任务队列,存储等待执行的任务。\n\n**④、handler**:拒绝策略,任务超载时的处理方式。也就是线程数达到 maximumPoolSiz,任务队列也满了的时候,就会触发拒绝策略。\n\n**⑤、threadFactory**:线程工厂,用于创建线程,可自定义线程名。\n\n**⑥、keepAliveTime**:非核心线程的存活时间,空闲时间超过该值就销毁。\n\n**⑦、unit**:keepAliveTime 参数的时间单位:\n\n* TimeUnit.DAYS; 天\n* TimeUnit.HOURS; 小时\n* TimeUnit.MINUTES; 分钟\n* TimeUnit.SECONDS; 秒\n* TimeUnit.MILLISECONDS; 毫秒\n* TimeUnit.MICROSECONDS; 微秒\n* TimeUnit.NANOSECONDS; 纳秒\n\n#### [能简单说一下参数之间的关系吗?](#能简单说一下参数之间的关系吗)\n\n一句话:任务优先使用核心线程执行,满了进入等待队列,队列满了启用非核心线程备用,线程池达到最大线程数量后触发拒绝策略,非核心线程的空闲时间超过存活时间就被回收。\n\n#### [核心线程数不够会怎么进行处理?](#核心线程数不够会怎么进行处理)\n\n当提交的任务数超过了 corePoolSize,但是小于 maximumPoolSize 时,线程池会创建新的线程来处理任务。\n\n当提交的任务数超过了 maximumPoolSize 时,线程池会根据拒绝策略来处理任务。\n\n#### [举个例子说一下这些参数的变化?](#举个例子说一下这些参数的变化)\n\n假设一个场景,线程池的配置如下:\n\n\n```java\ncorePoolSize = 5\nmaximumPoolSize = 10\nkeepAliveTime = 60秒\nworkQueue = LinkedBlockingQueue(容量为100)\nhandler = ThreadPoolExecutor.AbortPolicy()\n```\n\n\n**场景一**:当系统启动后,有 10 个任务提交到线程池。\n\n* 前 5 个任务会立即执行,因为核心线程数足够容纳它们。\n* 随后的 5 个任务会被放入等待队列。\n\n**场景二**:如果此时再有 100 个任务提交到线程池。\n\n* 工作队列已满,线程池会创建额外的线程来执行这些任务,直到线程总数达到 10。\n* 如果任务继续增加,超过了工作队列+最大线程数的限制,新来的任务会被 AbortPolicy 拒绝,抛出 RejectedExecutionException 异常。\n\n**场景三**:如果任务突然减少:\n\n核心线程会一直运行,而超出核心线程数的线程,会在 60 秒后回收。"
+ },
+ {
+ "id": 143,
+ "question": "线程池的拒绝策略有哪些?",
+ "answer": "有四种:\n\n* AbortPolicy:默认的拒绝策略,会抛 RejectedExecutionException 异常。\n* CallerRunsPolicy:让提交任务的线程自己来执行这个任务,也就是调用 execute 方法的线程。\n* DiscardOldestPolicy:等待队列会丢弃队列中最老的一个任务,也就是队列中等待最久的任务,然后尝试重新提交被拒绝的任务。\n* DiscardPolicy:丢弃被拒绝的任务,不做任何处理也不抛出异常。\n\n\n\n分别对应着小二去银行办理业务被经理“薄纱”的四个场景:“我们系统瘫痪了”、“谁叫你来办的你找谁去”、“看你比较急,去队里加个塞”、“今天没办法,不行你看改一天”。\n\n当线程池无法接受新的任务时,也就是线程数达到 maximumPoolSize,任务队列也满了的时候,就会触发拒绝策略。\n\n如果默认策略不能满足需求,可以通过实现 RejectedExecutionHandler 接口来定义自己的淘汰策略。例如:记录被拒绝任务的日志。\n\n\n```java\nclass CustomRejectedHandler {\n public static void main(String[] args) {\n // 自定义拒绝策略\n RejectedExecutionHandler rejectedHandler = (r, executor) -> {\n System.out.println(\"Task \" + r.toString() + \" rejected. Queue size: \" \n + executor.getQueue().size());\n };\n\n // 自定义线程池\n ThreadPoolExecutor executor = new ThreadPoolExecutor(\n 2, // 核心线程数\n 4, // 最大线程数\n 10, // 空闲线程存活时间\n TimeUnit.SECONDS,\n new ArrayBlockingQueue<>(2), // 阻塞队列容量\n Executors.defaultThreadFactory(),\n rejectedHandler // 自定义拒绝策略\n );\n\n for (int i = 0; i < 10; i++) {\n final int taskNumber = i;\n executor.execute(() -> {\n System.out.println(\"Executing task \" + taskNumber);\n try {\n Thread.sleep(1000); // 模拟任务耗时\n } catch (InterruptedException e) {\n e.printStackTrace();\n }\n });\n }\n\n executor.shutdown();\n }\n}\n```"
+ },
+ {
+ "id": 144,
+ "question": "线程池有哪几种阻塞队列?",
+ "answer": "常用的有五种,有界队列 ArrayBlockingQueue;无界队列 LinkedBlockingQueue;优先级队列 PriorityBlockingQueue;延迟队列 DelayQueue;同步队列 SynchronousQueue。\n\n\n\n①、ArrayBlockingQueue:一个有界的先进先出的阻塞队列,底层是一个数组,适合固定大小的线程池。\n\n\n```java\nArrayBlockingQueue blockingQueue = new ArrayBlockingQueue(10, true);\n```\n\n\n②、LinkedBlockingQueue:底层是链表,如果不指定大小,默认大小是 Integer.MAX\\_VALUE,几乎相当于一个无界队列。\n\n中,就使用了 LinkedBlockingQueue 来配置 RabbitMQ 的消息队列。\n\n③、PriorityBlockingQueue:一个支持优先级排序的无界阻塞队列。任务按照其自然顺序或 Comparator 来排序。\n\n适用于需要按照给定优先级处理任务的场景,比如优先处理紧急任务。\n\n④、DelayQueue:类似于 PriorityBlockingQueue,由二叉堆实现的无界优先级阻塞队列。\n\nExecutors 中的 `newScheduledThreadPool()` 就使用了 DelayQueue 来实现延迟执行。\n\n\n```java\npublic ScheduledThreadPoolExecutor(int corePoolSize) {\n super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,\n new DelayedWorkQueue());\n}\n```\n\n\n⑤、SynchronousQueue:每个插入操作必须等待另一个线程的移除操作,同样,任何一个移除操作都必须等待另一个线程的插入操作。\n\n`Executors.newCachedThreadPool()` 就使用了 SynchronousQueue,这个线程池会根据需要创建新线程,如果有空闲线程则会重复使用,线程空闲 60 秒后会被回收。\n\n\n```java\npublic static ExecutorService newCachedThreadPool() {\n return new ThreadPoolExecutor(0, Integer.MAX_VALUE,\n 60L, TimeUnit.SECONDS,\n new SynchronousQueue());\n}\n```"
+ },
+ {
+ "id": 145,
+ "question": "线程池提交 execute 和 submit 有什么区别?",
+ "answer": "execute 方法没有返回值,适用于不关心结果和异常的简单任务。\n\n\n```java\nthreadsPool.execute(new Runnable() {\n @Override public void run() {\n System.out.println(\"execute() 方法提交的任务\");\n }\n});\n```\n\n\nsubmit 有返回值,适用于需要获取结果或处理异常的场景。\n\n\n```java\nFuture future = executor.submit(harReturnValuetask);\ntry { Object s = future.get(); } \ncatch (InterruptedException e | ExecutionException e) {\n // 处理无法执行任务异常\n} finally {\n // 关闭线程池 executor.shutdown();\n}\n```"
+ },
+ {
+ "id": 146,
+ "question": "线程池怎么关闭知道吗?",
+ "answer": "可以调用线程池的`shutdown`或`shutdownNow`方法来关闭线程池。\n\nshutdown 不会立即停止线程池,而是等待所有任务执行完毕后再关闭线程池。\n\n\n```java\nExecutorService executor = Executors.newFixedThreadPool(3);\nexecutor.execute(() -> System.out.println(\"Task 1\"));\nexecutor.execute(() -> System.out.println(\"Task 2\"));\n\nexecutor.shutdown(); // 不会立刻关闭,而是等待所有任务执行完毕\n```\n\n\nshutdownNow 会尝试通过一系列动作来停止线程池,包括停止接收外部提交的任务、忽略队列里等待的任务、尝试将正在跑的任务 interrupt 中断。\n\n\n```java\nExecutorService executor = Executors.newFixedThreadPool(3);\nexecutor.execute(() -> {\n try {\n Thread.sleep(5000); // 模拟长时间运行任务\n System.out.println(\"Task executed\");\n } catch (InterruptedException e) {\n System.out.println(\"任务被中断\");\n }\n});\n\nList unexecutedTasks = executor.shutdownNow(); // 立即关闭线程池\nSystem.out.println(\"未执行的任务数: \" + unexecutedTasks.size());\n```\n\n\n需要注意的是,shutdownNow 不会真正终止正在运行的任务,只是给任务线程发送 interrupt 信号,任务是否能真正终止取决于线程是否响应 InterruptedException。"
+ },
+ {
+ "id": 147,
+ "question": "线程池的线程数应该怎么配置?",
+ "answer": "首先,我会分析线程池中执行的任务类型是 CPU 密集型还是 IO 密集型?\n\n①、对于 CPU 密集型任务,我的目标是尽量减少线程上下文切换,以优化 CPU 使用率。一般来说,核心线程数设置为处理器的核心数或核心数加一是较理想的选择。\n\n> +1 是为了以备不时之需,如果某线程因等待系统资源而阻塞时,可以有多余的线程顶上去,不至于影响整体性能。\n\n②、对于 IO 密集型任务,由于线程经常处于等待状态,等待 IO 操作完成,所以可以设置更多的线程来提高并发,比如说 CPU 核心数的两倍。\n\n\n> 核心数可以通过 Java 的`Runtime.getRuntime().availableProcessors()`方法获取。\n\n最后,我会根据业务需求和系统资源来调整线程池的其他参数,比如最大线程数、任务队列容量、非核心线程的空闲存活时间等。\n\n\n```java\nThreadPoolExecutor executor = new ThreadPoolExecutor(\n cores, // 核心线程数设置为CPU核心数\n cores * 2, // 最大线程数为核心数的两倍\n 60L, TimeUnit.SECONDS, // 非核心线程的空闲存活时间\n new LinkedBlockingQueue<>(100) // 任务队列容量\n);\n```\n\n\n#### [如何知道你设置的线程数多了还是少了?](#如何知道你设置的线程数多了还是少了)\n\n可以通过监控和调试来判断线程数是多还是少。\n\n比如说通过 top 命令观察 CPU 的使用率,如果 CPU 使用率较低,可能是线程数过少;如果 CPU 使用率接近 100%,但吞吐量未提升,可能是线程数过多。\n\n然后再通过 VisualVM 或 Arthas 分析线程运行情况,查看线程的状态、等待时间、运行时间等信息。\n\n也可以使用 jstack 命令查看线程堆栈信息,查看线程是否处于阻塞状态。\n\n\n```java\njstack | grep -A 20 \"BLOCKED\" // 查看阻塞线程\n```\n\n\n如果有大量的 BLOCKED 线程,说明线程数可能过多,竞争比较激烈。"
+ },
+ {
+ "id": 148,
+ "question": "有哪几种常见的线程池?",
+ "answer": "主要有四种:\n\n固定大小的线程池 `Executors.newFixedThreadPool(int nThreads);`,适合用于任务数量确定,且对线程数有明确要求的场景。例如,IO 密集型任务、数据库连接池等。\n\n缓存线程池 `Executors.newCachedThreadPool();`,适用于短时间内任务量波动较大的场景。例如,短时间内有大量的文件处理任务或网络请求。\n\n定时任务线程池 `Executors.newScheduledThreadPool(int corePoolSize);`,适用于需要定时执行任务的场景。例如,定时发送邮件、定时备份数据等。\n\n单线程线程池 `Executors.newSingleThreadExecutor();`,适用于需要按顺序执行任务的场景。例如,日志记录、文件处理等。"
+ },
+ {
+ "id": 149,
+ "question": "能说一下四种常见线程池的原理吗?",
+ "answer": "不管是 FixedThreadPool、CachedThreadPool,还是 SingleThreadExecutor 和 ScheduledThreadPoolExecutor,它们本质上都是 ThreadPoolExecutor 的不同配置。\n\n#### [说说固定大小线程池的原理?](#说说固定大小线程池的原理)\n\n线程池大小是固定的,`corePoolSize == maximumPoolSize`,默认使用 LinkedBlockingQueue 作为阻塞队列,适用于任务量稳定的场景,如数据库连接池、RPC 处理等。\n\n\n```java\nnew ThreadPoolExecutor(4, 4, 0L, TimeUnit.MILLISECONDS,\n new LinkedBlockingQueue<>());\n```\n\n\n新任务提交时,如果线程池有空闲线程,直接执行;如果没有,任务进入 LinkedBlockingQueue 等待。缺点是任务队列默认无界,可能导致任务堆积,甚至 OOM。\n\n\n\n#### [说说缓存线程池的原理?](#说说缓存线程池的原理)\n\n线程池大小不固定,`corePoolSize = 0`,`maximumPoolSize = Integer.MAX_VALUE`。空闲线程超过 60 秒会被销毁,使用 SynchronousQueue 作为阻塞队列,适用于短时间内有大量任务的场景。\n\n\n```java\nnew ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS,\n new SynchronousQueue<>());\n```\n\n\n提交任务时,如果线程池没有空闲线程,直接新建线程执行任务;如果有,复用线程执行任务。线程空闲 60 秒后销毁,减少资源占用。缺点是线程数没有上限,在高并发情况下可能导致 OOM。\n\n\n\n#### [说说单线程线程池的原理?](#说说单线程线程池的原理)\n\n线程池只有 1 个线程,保证任务按提交顺序执行,使用 LinkedBlockingQueue 作为阻塞队列,适用于需要按顺序执行任务的场景。\n\n\n```java\nnew ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS,\n new LinkedBlockingQueue<>());\n```\n\n\n始终只创建 1 个线程,新任务必须等待前一个任务完成后才能执行,其他任务都被放入 LinkedBlockingQueue 排队执行。缺点是无法并行处理任务。\n\n\n\n#### [说说定时任务线程池的原理?](#说说定时任务线程池的原理)\n\n定时任务线程池的大小可配置,支持定时 & 周期性任务执行,使用 DelayedWorkQueue 作为阻塞队列,适用于周期性执行任务的场景。\n\n\n```java\npublic ScheduledThreadPoolExecutor(int corePoolSize) {\n super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,\n new DelayedWorkQueue());\n}\n```\n\n\n执行定时任务时,`schedule()` 方法可以将任务延迟一定时间后执行一次;`scheduleAtFixedRate()` 方法可以将任务延迟一定时间后以固定频率执行;`scheduleWithFixedDelay()` 方法可以将任务延迟一定时间后以固定延迟执行。\n\n\n\n缺点是,如果任务执行时间 `>` 设定时间间隔,scheduleAtFixedRate 可能会导致任务堆积。\n\n\n\n#### [使用无界队列的线程池会出现什么问题?](#使用无界队列的线程池会出现什么问题)\n\n如果线程获取一个任务后,任务的执行时间比较长,会导致队列的任务越积越多,导致内存使用不断飙升,最终出现 OOM。"
+ },
+ {
+ "id": 150,
+ "question": "线程池异常怎么处理知道吗?",
+ "answer": "常见的处理方式有,使用 try-catch 捕获、使用 Future 获取异常、自定义ThreadPoolExecutor 重写 afterExecute 方法、使用 UncaughtExceptionHandler 捕获异常。\n\n\n\n①、try-catch 是最简单的方法。\n\n\n```java\nexecutor.execute(() -> {\n try {\n System.out.println(\"任务开始\");\n int result = 1 / 0; // 除零异常\n } catch (Exception e) {\n System.err.println(\"捕获异常:\" + e.getMessage());\n }\n});\n```\n\n\n②、使用 Future 获取异常。\n\n\n```java\nFuture future = executor.submit(() -> {\n System.out.println(\"任务开始\");\n int result = 1 / 0; // 除零异常\n return result;\n});\n\ntry {\n future.get();\n} catch (InterruptedException | ExecutionException e) {\n System.err.println(\"捕获异常:\" + e.getMessage());\n}\n```\n\n\n③、自定义 ThreadPoolExecutor 重写 afterExecute 方法。\n\n\n```java\nThreadPoolExecutor executor = new ThreadPoolExecutor(2, 2, 0L, TimeUnit.MILLISECONDS,\n new LinkedBlockingQueue()) {\n @Override\n protected void afterExecute(Runnable r, Throwable t) {\n super.afterExecute(r, t);\n if (t != null) {\n System.err.println(\"捕获异常:\" + t.getMessage());\n }\n }\n};\n\nexecutor.execute(() -> {\n System.out.println(\"任务开始\");\n int result = 1 / 0; // 除零异常\n});\n```\n\n\n④、使用 UncaughtExceptionHandler 捕获异常。\n\n\n```java\nThreadPoolExecutor executor = new ThreadPoolExecutor(2, 2, 0L, TimeUnit.MILLISECONDS,\n new LinkedBlockingQueue());\nexecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());\nexecutor.setThreadFactory(new ThreadFactory() {\n @Override\n public Thread newThread(Runnable r) {\n Thread thread = new Thread(r);\n thread.setUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {\n @Override\n public void uncaughtException(Thread t, Throwable e) {\n System.err.println(\"捕获异常:\" + e.getMessage());\n }\n });\n return thread;\n }\n});\n\nexecutor.execute(() -> {\n System.out.println(\"任务开始\");\n int result = 1 / 0; // 除零异常\n});\n```\n\n\n如果项目使用 `execute()`,不关心任务返回值,建议使用 UncaughtExceptionHandler:\n\n\n```java\nthread.setUncaughtExceptionHandler((t, e) -> \n System.err.println(\"线程 \" + t.getName() + \" 捕获到异常:\" + e.getMessage()));\n```\n\n\n如果项目使用 `submit()`,关心任务返回值,建议使用 Future:\n\n\n```java\nFuture> future = executor.submit(task);\ntry {\n future.get();\n} catch (ExecutionException e) {\n System.err.println(\"捕获异常:\" + e.getCause());\n}\n```\n\n\n如果想要全局捕获所有任务异常,建议重写 afterExecute 方法:\n\n\n```java\nclass MyThreadPoolExecutor extends ThreadPoolExecutor {\n @Override\n protected void afterExecute(Runnable r, Throwable t) {\n if (t == null && r instanceof Future>) {\n try { ((Future>) r).get(); } catch (Exception e) { System.err.println(\"任务异常:\" + e.getCause()); }\n }\n }\n}\n```"
+ },
+ {
+ "id": 151,
+ "question": "能说一下线程池有几种状态吗?",
+ "answer": "有 5 种状态,它们的转换遵循严格的状态流转规则,不同状态控制着线程池的任务调度和关闭行为。\n\n状态由 RUNNING → SHUTDOWN → STOP → TIDYING → TERMINATED 依次流转。\n\n\n\n**RUNNING** 状态的线程池可以接收新任务,并处理阻塞队列中的任务;**SHUTDOWN** 状态的线程池不会接收新任务,但会处理阻塞队列中的任务;**STOP** 状态的线程池不会接收新任务,也不会处理阻塞队列中的任务,并且会尝试中断正在执行的任务;**TIDYING** 状态表示所有任务已经终止;**TERMINATED** 状态表示线程池完全关闭,所有线程销毁。\n\n| 状态 | 状态码 | 是否接收新任务 | 是否执行队列中的任务 | 是否中断正在执行的任务 |\n| --- | --- | --- | --- | --- |\n| RUNNING | 111 | ✅ 是 | ✅ 是 | ❌ 否 |\n| SHUTDOWN | 000 | ❌ 否 | ✅ 是 | ❌ 否 |\n| STOP | 001 | ❌ 否 | ❌ 否 | ✅ 是 |\n| TIDYING | 010 | ❌ 否 | ❌ 否 | ❌ 否 |\n| TERMINATED | 011 | ❌ 否 | ❌ 否 | ❌ 否 |"
+ },
+ {
+ "id": 152,
+ "question": "线程池如何实现参数的动态修改?",
+ "answer": "线程池提供的 setter 方法就可以在运行时动态修改参数,比如说 setCorePoolSize 可以用来修改核心线程数、setMaximumPoolSize 可以用来修改最大线程数。\n\n\n\n需要注意的是,调用 `setCorePoolSize()` 时如果新的核心线程数比原来的大,线程池会创建新的线程;如果更小,线程池不会立即销毁多余的线程,除非有空闲线程超过 keepAliveTime。\n\n当然了,还可以利用 Nacos 配置中心,或者实现自定义的线程池,监听参数变化去动态调整参数。\n\n"
+ },
+ {
+ "id": 153,
+ "question": "线程池调优了解吗?(补充)",
+ "answer": "\n\n首先我会根据任务类型设置核心线程数参数,比如 IO 密集型任务会设置为 CPU 核心数\\*2 的经验值。\n\n其次我会结合线程池动态调整的能力,在流量波动时通过 setCorePoolSize 平滑扩容,或者直接使用 DynamicTp 实现线程池参数的自动化调整。\n\n最后,我会通过内置的监控指标建立容量预警机制。比如通过 JMX 监控线程池的运行状态,设置阈值,当线程池的任务队列长度超过阈值时,触发告警。"
+ },
+ {
+ "id": 154,
+ "question": "线程池在使用的时候需要注意什么?(补充)",
+ "answer": "> 2024 年 03 月 16 日增补\n\n我认为有 3 个比较重要的关注点:\n\n第一个,选择合适的线程池大小。**过小**的线程池可能会导致任务一直在排队;**过大**的线程池可能会导致大家都在竞争 CPU 资源,增加上下文切换的开销\n\n第二个,选择合适的任务队列。使用有界队列可以避免资源耗尽的风险,但是可能会导致任务被拒绝;使用无界队列虽然可以避免任务被拒绝,但是可能会导致内存耗尽\n\n比如在使用 LinkedBlockingQueue 的时候,可以传入参数来限制队列中任务的数量,这样就不会出现 OOM。\n\n第三个,尽量使用自定义的线程池,而不是使用 Executors 创建的线程池。\n\n因为 newFixedThreadPool 线程池由于使用了 LinkedBlockingQueue,队列的容量默认无限大,任务过多时会导致内存溢出;newCachedThreadPool 线程池由于核心线程数无限大,当任务过多的时候会导致创建大量的线程,导致服务器负载过高宕机。"
+ },
+ {
+ "id": 155,
+ "question": "你能设计实现一个线程池吗?",
+ "answer": "线程池的主要目的是为了避免频繁地创建和销毁线程。\n\n\n\n我会把线程池看作一个工厂,里面有一群“工人”,也就是线程了,专门用来做任务。\n\n当任务来了,需要先判断有没有空闲的工人,如果有就把任务交给他们;如果没有,就把任务暂存到一个任务队列里,等工人忙完了再去处理。\n\n如果队列满了,还没有空闲的工人,就要考虑扩容,让预备的工人过来干活,但不能超过预定的最大值,防止工厂被挤爆。\n\n如果连扩容也没法解决,就需要一个拒绝策略,可能直接拒绝任务或者报个错。\n\n核心线程池类(可参考):\n\n\n```java\nclass CustomThreadPoolExecutor {\n\n private final int corePoolSize;\n private final int maximumPoolSize;\n private final long keepAliveTime;\n private final TimeUnit unit;\n private final BlockingQueue workQueue;\n private final RejectedExecutionHandler handler;\n\n private volatile boolean isShutdown = false;\n private int currentPoolSize = 0;\n\n // 构造方法\n public CustomThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit,\n BlockingQueue workQueue, RejectedExecutionHandler handler) {\n this.corePoolSize = corePoolSize;\n this.maximumPoolSize = maximumPoolSize;\n this.keepAliveTime = keepAliveTime;\n this.unit = unit;\n this.workQueue = workQueue;\n this.handler = handler;\n }\n\n // 提交任务\n public void execute(Runnable task) {\n if (isShutdown) {\n throw new IllegalStateException(\"ThreadPool is shutdown\");\n }\n\n synchronized (this) {\n // 如果当前线程数小于核心线程数,直接创建新线程\n if (currentPoolSize < corePoolSize) {\n new Worker(task).start();\n currentPoolSize++;\n return;\n }\n\n // 尝试将任务添加到队列中\n if (!workQueue.offer(task)) {\n if (currentPoolSize < maximumPoolSize) {\n new Worker(task).start();\n currentPoolSize++;\n } else {\n // 调用拒绝策略\n handler.rejectedExecution(task, null);\n }\n }\n }\n }\n\n // 关闭线程池\n public void shutdown() {\n isShutdown = true;\n }\n\n // 工作线程\n private class Worker extends Thread {\n private Runnable task;\n\n Worker(Runnable task) {\n this.task = task;\n }\n\n @Override\n public void run() {\n while (task != null || (task = getTask()) != null) {\n try {\n task.run();\n } finally {\n task = null;\n }\n }\n }\n\n // 从队列中获取任务\n private Runnable getTask() {\n try {\n return workQueue.poll(keepAliveTime, unit);\n } catch (InterruptedException e) {\n return null;\n }\n }\n }\n}\n```\n\n\n拒绝策略:\n\n\n```java\n/**\n * 拒绝策略\n */\nclass CustomRejectedExecutionHandler {\n\n // AbortPolicy 抛出异常\n public static class AbortPolicy implements RejectedExecutionHandler {\n public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {\n throw new RuntimeException(\"Task \" + r.toString() + \" rejected from \" + e.toString());\n }\n }\n\n // DiscardPolicy 什么都不做\n public static class DiscardPolicy implements RejectedExecutionHandler {\n public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {\n // Do nothing\n }\n }\n\n // DiscardOldestPolicy 丢弃队列中最旧的任务\n public static class CallerRunsPolicy implements RejectedExecutionHandler {\n public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {\n if (!e.isShutdown()) {\n r.run();\n }\n }\n }\n}\n```\n\n\n使用示例:\n\n\n```java\nclass ThreadPoolTest {\n public static void main(String[] args) {\n // 创建线程池\n CustomThreadPoolExecutor executor = new CustomThreadPoolExecutor(\n 2, 4, 10, TimeUnit.SECONDS,\n new LinkedBlockingQueue<>(2),\n new CustomRejectedExecutionHandler.AbortPolicy());\n\n // 提交任务\n for (int i = 0; i < 10; i++) {\n final int index = i;\n executor.execute(() -> {\n System.out.println(\"Task \" + index + \" is running\");\n try {\n Thread.sleep(2000);\n } catch (InterruptedException e) {\n e.printStackTrace();\n }\n });\n }\n\n // 关闭线程池\n executor.shutdown();\n }\n}\n```\n\n\n执行结果:\n\n\n\n#### [手写一个数据库连接池,可以吗?](#手写一个数据库连接池-可以吗)\n\n可以的,我的思路是这样的:数据库连接池主要是为了避免每次操作数据库时都去创建连接,因为那样很浪费资源。所以我打算在初始化时预先创建好固定数量的连接,然后把它们放到一个线程安全的容器里,后续有请求的时候就从队列里拿,使用完后再归还到队列中。\n\n\n```java\nclass SimpleConnectionPool {\n // 配置\n private String jdbcUrl;\n private String username;\n private String password;\n private int maxConnections;\n private BlockingQueue connectionPool;\n\n // 构造方法\n public SimpleConnectionPool(String jdbcUrl, String username, String password, int maxConnections) throws SQLException {\n this.jdbcUrl = jdbcUrl;\n this.username = username;\n this.password = password;\n this.maxConnections = maxConnections;\n this.connectionPool = new LinkedBlockingQueue<>(maxConnections);\n\n // 初始化连接池\n for (int i = 0; i < maxConnections; i++) {\n connectionPool.add(createNewConnection());\n }\n }\n\n // 创建新连接\n private Connection createNewConnection() throws SQLException {\n return DriverManager.getConnection(jdbcUrl, username, password);\n }\n\n // 获取连接\n public Connection getConnection(long timeout, TimeUnit unit) throws InterruptedException, SQLException {\n Connection connection = connectionPool.poll(timeout, unit); // 等待指定时间获取连接\n if (connection == null) {\n throw new SQLException(\"Timeout: Unable to acquire a connection.\");\n }\n return connection;\n }\n\n // 归还连接\n public void releaseConnection(Connection connection) throws SQLException {\n if (connection != null) {\n if (connection.isClosed()) {\n // 如果连接已关闭,创建一个新连接补充到池中\n connectionPool.add(createNewConnection());\n } else {\n // 将连接归还到池中\n connectionPool.offer(connection);\n }\n }\n }\n\n // 关闭所有连接\n public void closeAllConnections() throws SQLException {\n for (Connection connection : connectionPool) {\n if (!connection.isClosed()) {\n connection.close();\n }\n }\n }\n\n // 测试用例\n public static void main(String[] args) {\n try {\n SimpleConnectionPool pool = new SimpleConnectionPool(\n \"jdbc:mysql://localhost:3306/pai_coding\", \"root\", \"\", 5\n );\n\n // 获取连接\n Connection conn = pool.getConnection(5, TimeUnit.SECONDS);\n\n // 使用连接(示例查询)\n System.out.println(\"Connection acquired: \" + conn);\n Thread.sleep(2000); // 模拟查询\n\n // 归还连接\n pool.releaseConnection(conn);\n System.out.println(\"Connection returned.\");\n\n // 关闭所有连接\n pool.closeAllConnections();\n } catch (Exception e) {\n e.printStackTrace();\n }\n }\n}\n```\n\n\n运行结果:\n\n"
+ },
+ {
+ "id": 156,
+ "question": "线程池执行中断电了应该怎么处理?",
+ "answer": "线程池本身只能在内存中进行任务调度,并不会持久化,一旦断电,线程池里的所有任务和状态都会丢失。\n\n我会考虑以下几个方面:\n\n第一,持久化任务。可以将任务持久化到数据库或者消息队列中,等电恢复后再重新执行。\n\n第二,任务幂等性,需要保证任务是幂等的,也就是无论执行多少次,结果都一致。\n\n第三,恢复策略。当系统重启时,应该有一个恢复流程:检测上次是否有未完成的任务,将这些任务重新加载到线程池中执行,确保断电前的工作能够恢复。"
+ }
+ ]
+ },
+ {
+ "id": 25,
+ "categoryName": "并发容器和框架",
+ "questions": [
+ {
+ "id": 157,
+ "question": "Fork/Join 框架了解吗?",
+ "answer": "关于 Fork/Join 框架,我了解一些,它是 Java 7 引入的一个并行框架,主要用于分治算法的并行执行。这个框架通过将大的任务递归地分解成小任务,然后并行执行,最后再合并结果,以达到最高效率处理大量数据的目的。\n\n\n\nFork/Join 框架的核心理念是**分而治之**,将大任务拆分为多个小任务并行处理,最后再将这些小任务的结果汇总。\n\n就像是一个树形结构,根节点是一个大的任务,叶子节点是最小的子任务,每个任务都可能会被分裂成更小的子任务,直到达到某个临界点,任务再逐个执行。\n\n具体来说,Fork/Join 包括两个主要的类:\n\nForkJoinPool,一个特殊的线程池,底层使用了工作窃取算法,也就是当一个线程执行完自己的任务后,它可以窃取其他线程的任务,避免线程闲置。\n\n\n\nRecursiveTask 和 RecursiveAction,分别用于有返回值和无返回值的任务,这两个类都继承自 ForkJoinTask。\n\n\n```java\nclass ForkJoinExample {\n public static void main(String[] args) {\n int[] arr = new int[100];\n for (int i = 0; i < 100; i++) {\n arr[i] = i + 1; // 填充数据 1 到 100\n }\n\n // 创建 ForkJoinPool,默认使用可用的处理器核心数\n ForkJoinPool pool = new ForkJoinPool();\n\n // 创建 ForkJoin 任务\n SumTask task = new SumTask(arr, 0, arr.length);\n\n // 执行任务\n Integer result = pool.invoke(task);\n\n System.out.println(\"数组的和是: \" + result);\n }\n\n // 自定义任务,继承 RecursiveTask\n static class SumTask extends RecursiveTask {\n private int[] arr;\n private int start;\n private int end;\n\n public SumTask(int[] arr, int start, int end) {\n this.arr = arr;\n this.start = start;\n this.end = end;\n }\n\n @Override\n protected Integer compute() {\n if (end - start <= 10) { // 如果任务足够小,就直接计算\n int sum = 0;\n for (int i = start; i < end; i++) {\n sum += arr[i];\n }\n return sum;\n } else {\n // 否则拆分任务\n int mid = (start + end) / 2;\n SumTask left = new SumTask(arr, start, mid);\n SumTask right = new SumTask(arr, mid, end);\n\n // 分别执行子任务\n left.fork();\n right.fork();\n\n // 合并结果\n int leftResult = left.join();\n int rightResult = right.join();\n\n return leftResult + rightResult; // 汇总结果\n }\n }\n }\n}\n```"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": 4,
+ "topicName": "JVM",
+ "categories": [
+ {
+ "id": 26,
+ "categoryName": "引言",
+ "questions": [
+ {
+ "id": 158,
+ "question": "什么是 JVM?",
+ "answer": "JVM,也就是 Java 虚拟机,它是 Java 实现跨平台的基石。\n\n程序运行之前,需要先通过编译器将 Java 源代码文件编译成 Java 字节码文件;\n\n程序运行时,JVM 会对字节码文件进行逐行解释,翻译成机器码指令,并交给对应的操作系统去执行。\n\n\n\n这样就实现了 Java 一次编译,处处运行的特性。\n\n#### [说说 JVM 的其他特性?](#说说-jvm-的其他特性)\n\n①、JVM 可以自动管理内存,通过垃圾回收器回收不再使用的对象并释放内存空间。\n\n②、JVM 包含一个即时编译器 JIT,它可以在运行时将热点代码缓存到 codeCache 中,下次执行的时候不用再一行一行的解释,而是直接执行缓存后的机器码,执行效率会大幅提高。\n\n\n\n③、任何可以通过 Java 编译的语言,比如说 Groovy、Kotlin、Scala 等,都可以在 JVM 上运行。\n\n\n\n#### [为什么要学习 JVM?](#为什么要学习-jvm)\n\n学习 JVM 可以帮助我们开发者更好地优化程序性能、避免内存问题。\n\n比如说了解 JVM 的内存模型和垃圾回收机制,可以帮助我们更合理地配置内存、减少 GC 停顿。\n\n比如说掌握 JVM 的类加载机制可以帮助我们排查类加载冲突或异常。\n\n再比如说,JVM 还提供了很多调试和监控工具,可以帮助我们分析内存和线程的使用情况,从而解决内存溢出内存泄露等问题。"
+ },
+ {
+ "id": 159,
+ "question": "说说 JVM 的组织架构(补充)",
+ "answer": "> 增补于 2024 年 03 月 08 日。\n\nJVM 大致可以划分为三个部分:类加载器、运行时数据区和执行引擎。\n\n\n\n① 类加载器,负责从文件系统、网络或其他来源加载 Class 文件,将 Class 文件中的二进制数据读入到内存当中。\n\n② 运行时数据区,JVM 在执行 Java 程序时,需要在内存中分配空间来处理各种数据,这些内存区域按照 Java 虚拟机规范可以划分为方法区、堆、虚拟机栈、程序计数器和本地方法栈。\n\n③ 执行引擎,也是 JVM 的心脏,负责执行字节码。它包括一个虚拟处理器、即时编译器 JIT 和垃圾回收器。"
+ }
+ ]
+ },
+ {
+ "id": 27,
+ "categoryName": "内存管理",
+ "questions": [
+ {
+ "id": 160,
+ "question": "能说一下 JVM 的内存区域吗?",
+ "answer": "按照 Java 虚拟机规范,JVM 的内存区域可以细分为`程序计数器`、`虚拟机栈`、`本地方法栈`、`堆`和`方法区`。\n\n\n\n其中`方法区`和`堆`是线程共享的,`虚拟机栈`、`本地方法栈`和`程序计数器`是线程私有的。\n\n#### [介绍一下程序计数器?](#介绍一下程序计数器)\n\n程序计数器也被称为 PC 寄存器,是一块较小的内存空间。它可以看作是当前线程所执行的字节码行号指示器。\n\n#### [介绍一下 Java 虚拟机栈?](#介绍一下-java-虚拟机栈)\n\nJava 虚拟机栈的生命周期与线程相同。\n\n当线程执行一个方法时,会创建一个对应的,用于存储局部变量表、操作数栈、动态链接、方法出口等信息,然后栈帧会被压入虚拟机栈中。当方法执行完毕后,栈帧会从虚拟机栈中移除。\n\n\n\n#### [一个什么都没有的空方法,空的参数都没有,那局部变量表里有没有变量?](#一个什么都没有的空方法-空的参数都没有-那局部变量表里有没有变量)\n\n对于,由于不需要访问实例对象 this,因此在局部变量表中不会有任何变量。\n\n对于非静态方法,即使是一个完全空的方法,局部变量表中也会有一个用于存储 this 引用的变量。this 引用指向当前实例对象,在方法调用时被隐式传入。\n\n详细解释一下:\n\n比如说有这样一段代码:\n\n\n```java\npublic class VarDemo1 {\n public void emptyMethod() {\n // 什么都没有\n }\n\n public static void staticEmptyMethod() {\n // 什么都没有\n }\n}\n```\n\n\n用 `javap -v VarDemo1` 命令查看编译后的字节码,就可以在 emptyMethod 中看到这样的内容:\n\n\n\n这里的 `locals=1` 表示局部变量表有一个变量,即 this,Slot 0 位置存储了 this 引用。\n\n而在静态方法 staticEmptyMethod 中,你会看到这样的内容:\n\n\n\n这里的 locals=0 表示局部变量表为空,因为静态方法属于类级别方法,不需要 this 引用,也就没有局部变量。\n\n#### [介绍一下本地方法栈?](#介绍一下本地方法栈)\n\n本地方法栈与虚拟机栈相似,区别在于虚拟机栈是为 JVM 执行 Java 编写的方法服务的,而本地方法栈是为 Java 调用服务的,通常由 C/C++ 编写。\n\n在本地方法栈中,主要存放了 native 方法的局部变量、动态链接和方法出口等信息。当一个 Java 程序调用一个 native 方法时,JVM 会切换到本地方法栈来执行这个方法。\n\n#### [介绍一下本地方法栈的运行场景?](#介绍一下本地方法栈的运行场景)\n\n当 Java 应用需要与操作系统底层或硬件交互时,通常会用到本地方法栈。\n\n比如调用操作系统的特定功能,如内存管理、文件操作、系统时间、系统调用等。\n\n详细说明一下:\n\n比如说获取系统时间的 `System.currentTimeMillis()` 方法就是调用本地方法,来获取操作系统当前时间的。\n\n\n\n再比如 JVM 自身的一些底层功能也需要通过本地方法来实现。像 Object 类中的 `hashCode()` 方法、`clone()` 方法等。\n\n\n\n#### [native 方法解释一下?](#native-方法解释一下)\n\nnative 方法是在 Java 中通过 声明的,用于调用非 Java 语言,如 C/C++ 编写的代码。Java 可以通过 JNI,也就是 Java Native Interface 与底层系统、硬件设备、或者本地库进行交互。\n\n#### [介绍一下 Java 堆?](#介绍一下-java-堆)\n\n堆是 JVM 中最大的一块内存区域,被所有线程共享,在 JVM 启动时创建,主要用来存储 new 出来的对象。\n\n\n\nJava 中“几乎”所有的对象都会在堆中分配,堆也是管理的目标区域。\n\n从内存回收的角度来看,由于垃圾收集器大部分都是基于分代收集理论设计的,所以堆又被细分为`新生代`、`老年代`、`Eden空间`、`From Survivor空间`、`To Survivor空间`等。\n\n\n\n随着 的发展和逃逸技术的逐渐成熟,“所有的对象都会分配到堆上”就不再那么绝对了。\n\n从 JDK 7 开始,JVM 默认开启了逃逸分析,意味着如果某些方法中的对象引用没有被返回或者没有在方法体外使用,也就是未逃逸出去,那么对象可以直接在栈上分配内存。\n\n#### [堆和栈的区别是什么?](#堆和栈的区别是什么)\n\n堆属于线程共享的内存区域,几乎所有 new 出来的对象都会堆上分配,生命周期不由单个方法调用所决定,可以在方法调用结束后继续存在,直到不再被任何变量引用,最后被垃圾收集器回收。\n\n栈属于线程私有的内存区域,主要存储局部变量、方法参数、对象引用等,通常随着方法调用的结束而自动释放,不需要垃圾收集器处理。\n\n#### [介绍一下方法区?](#介绍一下方法区)\n\n方法区并不真实存在,属于 Java 虚拟机规范中的一个逻辑概念,用于存储已被 JVM 加载的类信息、常量、静态变量、即时编译器编译后的代码缓存等。\n\n在 HotSpot 虚拟机中,方法区的实现称为永久代 PermGen,但在 Java 8 及之后的版本中,已经被元空间 Metaspace 所替代。\n\n#### [变量存在堆栈的什么位置?](#变量存在堆栈的什么位置)\n\n对于局部变量,它存储在当前方法栈帧中的局部变量表中。当方法执行完毕,栈帧被回收,局部变量也会被释放。\n\n\n```java\npublic void method() {\n int localVar = 100; // 局部变量,存储在栈帧中的局部变量表里\n}\n```\n\n\n对于静态变量来说,它存储在 Java 虚拟机规范中的方法区中,在 Java 7 中是永久带,在 Java8 及以后 是元空间。\n\n\n```java\npublic class StaticVarDemo {\n public static int staticVar = 100; // 静态变量,存储在方法区中\n}\n```"
+ },
+ {
+ "id": 161,
+ "question": "说一下 JDK 1.6、1.7、1.8 内存区域的变化?",
+ "answer": "JDK 1.6 使用永久代来实现方法区:\n\n\n\nJDK 1.7 时仍然是永久带,但发生了一些细微变化,比如将字符串常量池、静态变量存放到了堆上。\n\n\n\n在 JDK 1.8 时,直接在内存中划出了一块区域,叫**元空间**,来取代之前放在 JVM 内存中的永久代,并将运行时常量池、类常量池都移动到了元空间。\n\n"
+ },
+ {
+ "id": 162,
+ "question": "为什么使用元空间替代永久代?",
+ "answer": "客观上,永久代会导致 Java 应用程序更容易出现内存溢出的问题,因为它要受到 JVM 内存大小的限制。\n\nHotSpot 虚拟机的永久代大小可以通过 `-XX:MaxPermSize` 参数来设置,32 位机器默认的大小为 64M,64 位的机器则为 85M。\n\n而 J9 和 JRockit 虚拟机就不存在这种限制,只要没有触碰到进程可用的内存上限,例如 32 位系统中的 4GB 限制,就不会出问题。\n\n主观上,当 Oracle 收购 BEA 获得了 JRockit 的所有权后,就准备把 JRockit 中的优秀功能移植到 HotSpot 中。\n\n如 Java Mission Control 管理工具。\n\n但因为两个虚拟机对方法区实现有差异,导致这项工作遇到了很多阻力。\n\n考虑到 HotSpot 虚拟机未来的发展,JDK 6 的时候,开发团队就打算放弃永久代了。\n\nJDK 7 的时候,前进了一小步,把原本放在永久代的字符串常量池、静态变量等移动到了堆中。\n\nJDK 8 就终于完成了这项移出工作,这样的好处就是,元空间的大小不再受到 JVM 内存的限制,而是可以像 J9 和 JRockit 那样,只要系统内存足够,就可以一直用。"
+ },
+ {
+ "id": 163,
+ "question": "对象创建的过程了解吗?",
+ "answer": "当我们使用 new 关键字创建一个对象时,JVM 首先会检查 new 指令的参数是否能在常量池中定位到类的符号引用,然后检查这个符号引用代表的类是否已被加载、解析和初始化。如果没有,就先执行类加载。\n\n\n\n如果已经加载,JVM 会为对象分配内存完成初始化,比如数值类型的成员变量初始值是 0,布尔类型是 false,对象类型是 null。\n\n接下来会设置对象头,里面包含了对象是哪个类的实例、对象的哈希码、对象的 GC 分代年龄等信息。\n\n最后,JVM 会执行构造方法 `` 完成赋值操作,将成员变量赋值为预期的值,比如 `int age = 18`,这样一个对象就创建完成了。\n\n#### [对象的销毁过程了解吗?](#对象的销毁过程了解吗)\n\n当对象不再被任何引用指向时,就会变成垃圾。垃圾收集器会通过可达性分析算法判断对象是否存活,如果对象不可达,就会被回收。\n\n垃圾收集器通过标记清除、标记复制、标记整理等算法来回收内存,将对象占用的内存空间释放出来。\n\n可以通过 `java -XX:+PrintCommandLineFlags -version` 和 `java -XX:+PrintGCDetails -version` 命令查看 JVM 的 GC 收集器。\n\n\n\n可以看到,我本机安装的 JDK 8 默认使用的是 `Parallel Scavenge + Parallel Old`。\n\n不同参数代表对应的垃圾收集器表单:\n\n| 新生代 | 老年代 | JVM参数 |\n| --- | --- | --- |\n| Serial | Serial | -XX:+UseSerialGC |\n| Parallel Scavenge | Serial | -XX:+UseParallelGC -XX:-UseParallelOldGC |\n| Parallel Scavenge | Parallel Old | -XX:+UseParallelGC -XX:+UseParallelOldGC |\n| Parallel New | CMS | -XX:+UseParNewGC -XX:+UseConcMarkSweepGC |\n| G1 | | -XX:+UseG1GC |"
+ },
+ {
+ "id": 164,
+ "question": "堆内存是如何分配的?",
+ "answer": "在堆中为对象分配内存时,主要使用两种策略:指针碰撞和空闲列表。\n\n\n\n指针碰撞适用于管理简单、碎片化较少的内存区域,如年轻代;而空闲列表适用于内存碎片化较严重或对象大小差异较大的场景如老年代。\n\n#### [什么是指针碰撞?](#什么是指针碰撞)\n\n假设堆内存是一个连续的空间,分为两个部分,一部分是已经被使用的内存,另一部分是未被使用的内存。\n\n在分配内存时,Java 虚拟机会维护一个指针,指向下一个可用的内存地址,每次分配内存时,只需要将指针向后移动一段距离,如果没有发生碰撞,就将这段内存分配给对象实例。\n\n#### [什么是空闲列表?](#什么是空闲列表)\n\nJVM 维护一个列表,记录堆中所有未占用的内存块,每个内存块都记录有大小和地址信息。\n\n当有新的对象请求内存时,JVM 会遍历空闲列表,寻找足够大的空间来存放新对象。\n\n分配后,如果选中的内存块未被完全利用,剩余的部分会作为一个新的内存块加入到空闲列表中。"
+ },
+ {
+ "id": 165,
+ "question": "new 对象时,堆会发生抢占吗?",
+ "answer": "会。\n\n\n\nnew 对象时,指针会向右移动一个对象大小的距离,假如一个线程 A 正在给字符串对象 s 分配内存,另外一个线程 B 同时为 ArrayList 对象 l 分配内存,两个线程就发生了抢占。\n\n#### [JVM 怎么解决堆内存分配的竞争问题?](#jvm-怎么解决堆内存分配的竞争问题)\n\n为了解决堆内存分配的竞争问题,JVM 为每个线程保留了一小块内存空间,被称为 TLAB,也就是线程本地分配缓冲区,用于存放该线程分配的对象。\n\n\n\n当线程需要分配对象时,直接从 TLAB 中分配。只有当 TLAB 用尽或对象太大需要直接在堆中分配时,才会使用全局分配指针。\n\n这里简单测试一下 TLAB。\n\n可以通过 `java -XX:+PrintFlagsFinal -version | grep TLAB` 命令查看当前 JVM 是否开启了 TLAB。\n\n\n\n如果开启了 TLAB,会看到类似以下的输出,其中 bool UseTLAB 的值为 true。\n\n我们编写一个简单的测试类,创建大量对象并强制触发垃圾回收,查看 TLAB 的使用情况。\n\n\n```java\nclass TLABDemo {\n public static void main(String[] args) {\n for (int i = 0; i < 10_000_000; i++) {\n allocate(); // 创建大量对象\n }\n System.gc(); // 强制触发垃圾回收\n }\n\n private static void allocate() {\n // 小对象分配,通常会使用 TLAB\n byte[] bytes = new byte[64];\n }\n}\n```\n\n\n在 VM 参数中添加 `-XX:+UseTLAB -XX:+PrintTLAB -XX:+PrintGCDetails -XX:+PrintGCDateStamps`,运行后可以看到这样的内容:\n\n\n\n* waste:未使用的 TLAB 空间。\n* alloc:分配到 TLAB 的空间。\n* refills:TLAB 被重新填充的次数。\n\n可以看到,当前线程的 TLAB 目标大小为 10,496 KB(`desired_size: 10496KB`);未发生慢分配(`slow allocs: 0`);分配效率直接拉满(`alloc: 1.00000 52494KB`)。\n\n当使用 `-XX:-UseTLAB -XX:+PrintGCDetails` 关闭 TLAB 时,会看到类似以下的输出:\n\n\n\n直接出现了两次 GC,因为没有 TLAB,Eden 区更快被填满,导致年轻代 GC。年轻代 GC 频繁触发,一部分长生命周期对象被晋升到老年代,间接导致老年代 GC 触发。"
+ },
+ {
+ "id": 166,
+ "question": "能说一下对象的内存布局吗?",
+ "answer": "好的。\n\n对象的内存布局是由 Java 虚拟机规范定义的,但具体的实现细节各有不同,如 HotSpot 和 OpenJ9 就不一样。\n\n就拿我们常用的 HotSpot 来说吧。\n\n对象在内存中包括三部分:对象头、实例数据和对齐填充。\n\n\n\n#### [说说对象头的作用?](#说说对象头的作用)\n\n对象头是对象存储在内存中的元信息,包含了Mark Word、类型指针等信息。\n\nMark Word 存储了对象的运行时状态信息,包括锁、哈希值、GC 标记等。在 64 位操作系统下占 8 个字节,32 位操作系统下占 4 个字节。\n\n类型指针指向对象所属类的元数据,也就是 Class 对象,用来支持多态、方法调用等功能。\n\n除此之外,如果对象是数组类型,还会有一个额外的数组长度字段。占 4 个字节。\n\n#### [类型指针会被压缩吗?](#类型指针会被压缩吗)\n\n类型指针可能会被压缩,以节省内存空间。比如说在开启压缩指针的情况下占 4 个字节,否则占 8 个字节。在 JDK 8 中,压缩指针默认是开启的。\n\n可以通过 `java -XX:+PrintFlagsFinal -version | grep UseCompressedOops` 命令来查看 JVM 是否开启了压缩指针。\n\n\n\n如果压缩指针开启,输出结果中的 bool UseCompressedOops 值为 true。\n\n#### [实例数据了解吗?](#实例数据了解吗)\n\n了解一些。\n\n实例数据是对象实际的字段值,也就是成员变量的值,按照字段在类中声明的顺序存储。\n\n\n```java\nclass ObjectDemo {\n int age;\n String name;\n}\n```\n\n\nJVM 会对这些数据进行对齐/重排,以提高内存访问速度。\n\n#### [对齐填充了解吗?](#对齐填充了解吗)\n\n由于 JVM 的内存模型要求对象的起始地址是 8 字节对齐(64 位 JVM 中),因此对象的总大小必须是 8 字节的倍数。\n\n如果对象头和实例数据的总长度不是 8 的倍数,JVM 会通过填充额外的字节来对齐。\n\n比如说,如果对象头 + 实例数据 = 14 字节,则需要填充 2 个字节,使总长度变为 16 字节。\n\n#### [为什么非要进行 8 字节对齐呢?](#为什么非要进行-8-字节对齐呢)\n\n因为 CPU 进行内存访问时,一次寻址的指针大小是 8 字节,正好是 L1 缓存行的大小。如果不进行内存对齐,则可能出现跨缓存行访问,导致额外的缓存行加载,CPU 的访问效率就会降低。\n\n\n\n比如说上图中 obj1 占 6 个字节,由于没有对齐,导致这一行缓存中多了 2 个字节 obj2 的数据,当 CPU 访问 obj2 的时候,就会导致缓存行刷新。\n\n也就说,8 字节对齐,是为了效率的提高,以空间换时间的一种方案。\n\n\n\n#### [new Object() 对象的内存大小是多少?](#new-object-对象的内存大小是多少)\n\n一般来说,目前的操作系统都是 64 位的,并且 JDK 8 中的压缩指针是默认开启的,因此在 64 位的 JVM 上,`new Object()`的大小是 16 字节(12 字节的对象头 + 4 字节的对齐填充)。\n\n\n\n对象头的大小是固定的,在 32 位 JVM 上是 8 字节,在 64 位 JVM 上是 16 字节;如果开启了压缩指针,就是 12 字节。\n\n实例数据的大小取决于对象的成员变量和它们的类型。对于`new Object()`来说,由于默认没有成员变量,因此我们可以认为此时的实例数据大小是 0。\n\n假如 MyObject 对象有三个成员变量,分别是 int、long 和 byte 类型,那么它们占用的内存大小分别是 4 字节、8 字节和 1 字节。\n\n\n```java\nclass MyObject {\n int a; // 4 字节\n long b; // 8 字节\n byte c; // 1 字节\n}\n```\n\n\n考虑到对齐填充,MyObject 对象的总大小为 12(对象头) + 4(a) + 8(b) + 1(c) + 7(填充) = 32 字节。\n\n#### [用过 JOL 查看对象的内存布局吗?](#用过-jol-查看对象的内存布局吗)\n\n用过。\n\n[JOL](https://openjdk.org/projects/code-tools/jol/) 是一款分析 JVM 对象布局的工具。\n\n第一步,在 pom.xml 中引入 JOL 依赖:\n\n\n```xml\n\n org.openjdk.jol \n jol-core \n 0.9 \n \n```\n\n\n第二步,使用 JOL 编写代码示例:\n\n\n```java\npublic class JOLSample {\n public static void main(String[] args) {\n // 打印JVM详细信息(可选)\n System.out.println(VM.current().details());\n\n // 创建Object实例\n Object obj = new Object();\n\n // 打印Object实例的内存布局\n String layout = ClassLayout.parseInstance(obj).toPrintable();\n System.out.println(layout);\n }\n}\n```\n\n\n第三步,运行代码,查看输出结果:\n\n\n\n可以看到有 OFFSET、SIZE、TYPE DESCRIPTION、VALUE 这几个信息。\n\n* OFFSET:偏移地址,单位字节;\n* SIZE:占用的内存大小,单位字节;\n* TYPE DESCRIPTION:类型描述,其中 object header 为对象头;\n* VALUE:对应内存中当前存储的值,二进制 32 位;\n\n从上面的结果能看到,对象头是 12 个字节,还有 4 个字节的 padding,`new Object()` 一共 16 个字节。\n\n#### [对象的引用大小了解吗?](#对象的引用大小了解吗)\n\n在 64 位 JVM 上,未开启压缩指针时,对象引用占用 8 字节;开启压缩指针时,对象引用会被压缩到 4 字节。HotSpot 虚拟机默认是开启压缩指针的。\n\n\n\n我们来验证一下:\n\n\n```java\nclass ReferenceSizeExample {\n private static class ReferenceHolder {\n Object reference;\n }\n\n public static void main(String[] args) {\n System.out.println(VM.current().details());\n System.out.println(ClassLayout.parseClass(ReferenceHolder.class).toPrintable());\n }\n}\n```\n\n\n运行代码,查看输出结果:\n\n\n\nReferenceHolder.reference 的大小为 4 字节。"
+ },
+ {
+ "id": 167,
+ "question": "JVM 怎么访问对象的?",
+ "answer": "主流的方式有两种:句柄和直接指针。\n\n两种方式的区别在于,句柄是通过一个中间的句柄表来定位对象的,而直接指针则是通过引用直接指向对象的内存地址。\n\n优点是,对象被移动时只需要修改句柄表中的指针,而不需要修改对象引用本身。\n\n\n\n在直接指针访问中,引用直接存储对象的内存地址;对象的实例数据和类型信息都存储在堆中固定的内存区域。\n\n优点是访问速度更快,因为少了一次句柄的寻址操作。缺点是如果对象在内存中移动,引用需要更新为新的地址。\n\n\n\nHotSpot 虚拟机主要使用直接指针来进行对象访问。"
+ },
+ {
+ "id": 168,
+ "question": "说一下对象有哪几种引用?",
+ "answer": "四种,分别是强引用、软引用、弱引用和虚引用。\n\n\n\n强引用是 Java 中最常见的引用类型。使用 new 关键字赋值的引用就是强引用,只要强引用关联着对象,垃圾收集器就不会回收这部分对象,即使内存不足。\n\n\n```java\n// str 就是一个强引用\nString str = new String(\"练习伴侣二\");\n```\n\n\n软引用于描述一些非必须对象,通过 SoftReference 类实现。软引用的对象在内存不足时会被回收。\n\n\n```java\n// softRef 就是一个软引用\nSoftReference softRef = new SoftReference<>(new String(\"练习伴侣二\"));\n```\n\n\n弱引用用于描述一些短生命周期的非必须对象,如 ThreadLocal 中的 Entry,就是通过 WeakReference 类实现的。弱引用的对象会在下一次垃圾回收时会被回收,不论内存是否充足。\n\n\n```java\nstatic class Entry extends WeakReference> {\n /** The value associated with this ThreadLocal. */\n Object value;\n\n //节点类\n Entry(ThreadLocal> k, Object v) {\n //key赋值\n super(k);\n //value赋值\n value = v;\n }\n}\n```\n\n\n虚引用主要用来跟踪对象被垃圾回收的过程,通过 PhantomReference 类实现。虚引用的对象在任何时候都可能被回收。\n\n\n```java\n// phantomRef 就是一个虚引用\nPhantomReference