干货分享,感谢您的阅读!背景高频面试题基本总结回顾(含笔试高频算法整理)
最全文章见:Java高频面试基础知识点整理
(一)Java基础高频知识考点
针对人员:
1.全部人员都适用;
2.正式员工:针对应届+工作1-3年的面试者,本部分可部分考察;
3.外包员工:本部分一般加大比重。
1.基本类型和包装类区别
数据类型
- 基本类型:包括byte、short、int、long、float、double、char和boolean等8种基本数据类型。它们是直接存储数据值的,不具有方法和属性。
- 包装类:对应于每种基本类型,Java提供了相应的包装类,例如Byte、Short、Integer、Long、Float、Double、Character和Boolean等。包装类是引用类型,它们包装了对应基本类型的值,并提供了一些方法和属性来操作这些值。
对象和存储
- 基本类型:基本类型的变量直接存储在栈中,它们的值是直接存储的,没有指向其他对象的引用。
- 包装类:包装类是对象,存储在堆中,当创建包装类对象时,会在堆中分配内存,并将基本类型的值封装到包装类中。
自动装箱和拆箱
- 自动装箱:Java提供了自动装箱机制,即在需要包装类对象的地方,可以直接使用基本类型,系统会自动将其转换为对应的包装类对象。
- 拆箱:同样,当需要基本类型的值时,可以直接使用包装类对象,系统会自动将其转换为对应的基本类型值。
空值处理
- 基本类型:基本类型不能存储空值(null),因为它们不是对象,没有引用。
- 包装类:包装类可以存储空值(null),可以用于表示缺失数据或特殊情况。
性能
- 基本类型:由于基本类型直接存储值,因此处理速度更快,占用内存较少。
- 包装类:由于包装类是对象,需要额外的内存开销,并且在自动装箱和拆箱过程中会涉及到一些性能消耗。
在实际开发中,通常优先使用基本类型,只有在需要对象特性,例如泛型或集合中,才会使用对应的包装类。自动装箱和拆箱的特性使得基本类型和包装类之间的转换变得更加方便。
2.实例方法和静态方法有什么不一样?
实例方法是与类的实例相关联的,需要先创建类的实例才能调用;而静态方法则不依赖于类的实例,可以直接通过类名来调用。两者在访问权限、内存分配、重写和多态性等方面也有所不同。
特点 | 实例方法 | 静态方法 |
---|---|---|
调用方式 | 通过类的实例调用 | 直接通过类名调用 |
方法访问权限 | 可以访问实例变量和实例方法 | 不能直接访问实例变量和实例方法 |
内存分配 | 每次调用会分配内存来存储实例变量 | 不会分配内存来存储实例变量 |
重写与隐藏 | 可以被子类重写 | 不能被子类重写,但可以被子类隐藏 |
多态性 | 受对象实际类型影响 | 在编译时就确定了调用的方法 |
3.Java指向的是引用还是地址?怎么理解?(高频考点)
在Java中,变量存储的是引用而不是直接的内存地址。理解Java中变量指向的是引用而不是地址:
- 内存管理:Java的内存管理是由垃圾回收器负责的,它通过对引用的追踪和分析来确定对象的生命周期和回收时机。因此,Java中的变量存储的是对象的引用,而不是直接的内存地址。
- 对象生命周期:Java中的对象在堆内存中分配和销毁,而引用则用于访问这些对象。变量存储的引用指向对象所在的内存空间,可以通过引用来操作和访问对象的成员变量和方法。
- 引用的赋值和传递:在Java中,通过将引用赋值给变量或将引用传递给方法来操作对象。这意味着变量和方法参数存储的是引用,使得我们可以在不直接操作对象内存地址的情况下对对象进行操作。
- 引用的可变性:在Java中,引用是可变的,即可以通过改变引用的指向来指向不同的对象。这使得在程序执行过程中可以改变对象的访问方式,而不需要直接操作对象的内存地址。
因此,虽然在语义上可以说Java的变量指向对象的内存地址,但更准确地说,Java的变量存储的是引用,用于访问对象。这种引用的使用方式使得Java具有更高层次的内存管理和安全性,同时提供了更好的抽象和封装性。
4.Object类内的方法(高频考点)
Object是所有类的父类,任何类都默认继承Object。Object类到底实现了哪些方法?
clone方法:保护方法,实现对象的浅复制,只有实现了Cloneable接口才可以调用该方法,否则抛出CloneNotSupportedException异常。
getClass方法:final方法,获得运行时类型。
toString方法:该方法用得比较多,一般子类都有覆盖。
finalize方法:该方法用于释放资源。因为无法确定该方法什么时候被调用,很少使用。
equals方法:该方法是非常重要的一个方法。一般equals和==是不一样的,但是在Object中两者是一样的。子类一般都要重写这个方法。
hashCode方法:该方法用于哈希查找,重写了equals方法一般都要重写hashCode方法。这个方法在一些具有哈希功能的Collection中用到。一般必须满足obj1.equals(obj2)==true。可以推出obj1.hashCode()==obj2.hashCode(),但是hashCode相等不一定就满足equals。不过为了提高效率,应该尽量使上面两个条件接近等价。
wait方法:wait方法就是使当前线程等待该对象的锁,当前线程必须是该对象的拥有者,也就是具有该对象的锁。wait()方法一直等待,直到获得锁或者被中断。wait(long timeout)设定一个超时间隔,如果在规定时间内没有获得锁就返回。
调用该方法后当前线程进入睡眠状态,直到以下事件发生。
(1)其他线程调用了该对象的notify方法。
(2)其他线程调用了该对象的notifyAll方法。
(3)其他线程调用了interrupt中断该线程。
(4)时间间隔到了。
此时该线程就可以被调度了,如果是被中断的话就抛出一个InterruptedException异常。
notify方法:该方法唤醒在该对象上等待的某个线程。
notifyAll方法:该方法唤醒在该对象上等待的所有线程。
5.hashCode方法的作用?
hashCode()
方法是 Java 中 Object
类的一个方法,用于返回对象的哈希码(散列码)。它的作用是为了提高哈希表(如 HashMap
、HashSet
等)的性能。哈希码是一种用于快速定位对象存储位置的技术。在使用哈希表时,对象被存储在哈希桶(数组)中,哈希表会根据对象的哈希码来确定对象存储的位置,以便快速查找、插入或删除对象。
hashCode()
方法的具体作用包括:
-
作为哈希表的索引: 哈希表使用对象的哈希码来确定对象存储在数组中的位置。每个对象都有一个哈希码,哈希表会根据对象的哈希码来计算出存储位置,以便快速定位对象。
-
作为对象在集合中的唯一标识: 在使用集合类(如
HashSet
、HashMap
等)时,对象的哈希码用于检查对象是否已经存在于集合中。如果两个对象的哈希码相同,集合类会进一步调用对象的equals()
方法来比较对象的内容是否相同。 -
作为对象在分布式系统中的唯一标识: 在分布式系统中,对象的哈希码可以用于分片(Sharding)和路由等操作,以便将对象均匀分布在不同的节点上,提高系统的扩展性和性能。
因此,实现良好的 hashCode()
方法可以提高哈希表的性能和效率,并且能够在集合中正确地处理对象的唯一性和相等性。
6.Java中一个字符占多少个字节,扩展再问int、 long、double占多少字节?
在 Java 中,一个字符占用 2 个字节,即 16 位。这是因为 Java 使用 Unicode 字符集来表示字符,其中每个字符都用 16 位表示,因此一个字符占用 2 个字节。
至于其他基本数据类型的大小,可以根据 Java 虚拟机规范来确定:
- int: 一个 int 类型占用 4 个字节(32 位),范围为 -2^31 到 2^31-1。
- long: 一个 long 类型占用 8 个字节(64 位),范围为 -2^63 到 2^63-1。
- double: 一个 double 类型占用 8 个字节(64 位),表示双精度浮点数。
需要注意的是,Java 虚拟机规范中并没有强制规定各种基本数据类型的大小,而是要求实现者根据规范的要求来实现。因此,以上大小是 Java 中常见的实现方式,但并非所有的 Java 虚拟机都必须按此方式实现。
7.Boolean占几个字节?
未精确定义字节。
首先在Java中定义的八种基本数据类型中,除了其它七种类型都有明确的内存占用字节数外,就boolean类型没有给出具体的占用字节数,因为对虚拟机来说根本就不存在 boolean 这个类型,boolean类型在编译后会使用其他数据类型来表示。
boolean类型没有给出精确的定义,《Java虚拟机规范》给出了4个字节,和boolean数组1个字节的定义,具体还要看虚拟机实现是否按照规范来,所以1个字节、4个字节都是有可能的。这其实是运算效率和存储空间之间的博弈,两者都非常的重要。
8.Exception和Error
- Exception和Error都是继承了Throwable类,在java中只有Throwable类型的实例才可以被抛出(throw)或者捕获(catch),他是异常处理机制的基本组成类型。
- Exception和Error体现了java平台设计者对不同异常情况的分类,Exception是程序正常运行中,可以预料的意外情况,可能并且应该被捕获,进行相应的处理。
- Error是指正常情况下,不大可能出现的情况,绝大部分的Error都会导致程序(比如JVM自身)处于非正常状态,不可恢复状态。既然是非正常情况,所以不便于也不需要捕获,常见的比如OutOfMemoryError之类,都是Error的子类。
- Exception又分为可检查(checked)异常和不检查(unchecked)异常,可检查异常在源码里必须显示的进行捕获处理,这里是编译期检查的一部分。前面我们介绍的不可查的Error,是Throwable不是Exception。
- 不检查异常就是所谓的运行时异常,类似NullPointerException,ArrayIndexOutOfBoundsExceptin之类,通常是可以编码避免的逻辑错误,具体根据需要来判断是否需要捕获,并不会在编译器强制要求。
9.==和equals的区别?
在 Java 中,==
运算符和 equals()
方法是用于比较对象之间的差异的两种不同方式。
==
运算符:
==
运算符用于比较两个对象的引用是否指向同一个内存地址,即判断两个对象是否是同一个对象的引用。- 当
==
运算符用于比较基本数据类型时,它会比较它们的值。 - 在比较对象时,
==
比较的是对象的引用地址,如果两个对象的引用地址相同,则返回true
,表示这两个对象是同一个对象;如果引用地址不同,则返回false
。
equals()
方法:
equals()
方法是 Object 类的一个方法,用于比较两个对象的内容是否相等,即判断两个对象是否逻辑上相等。- 在 Object 类中的默认实现中,
equals()
方法的行为与==
运算符相同,即比较对象的引用地址。 - 但是,许多 Java 类库中的类(如 String、Integer 等)会重写
equals()
方法,以便比较对象的内容而不是引用地址。
==
运算符用于比较两个对象的引用地址是否相同,而 equals()
方法用于比较两个对象的内容是否相同。在实际应用中,如果需要比较对象的内容,通常应该使用 equals()
方法,而不是 ==
运算符。
10.String str="hello world"和String str=new String("hello world")的区别?(高频考点)
String str="hello world"
通过直接赋值的形式可能创建一个或者不创建对象,如果"hello world"在字符串池中不存在,会在java字符串池中创建一个String对象("hello world"),常量池中的值不能有重复的,所以当你通过这种方式创建对象的时候,java虚拟机会自动的在常量池中搜索有没有这个值,如果有的话就直接利用他的值,如果没有,他会自动创建一个对象,所以,str指向这个内存地址,无论以后用这种方式创建多少个值为"hello world"的字符串对象,始终只有一个内存地址被分配。
String str=new String("hello world")
通过new 关键字至少会创建一个对象,也有可能创建两个。
因为用到new关键字,肯定会在堆中创建一个String对象,如果字符池中已经存在"hello world",则不会在字符串池中创建一个String对象,如果不存在,则会在字符串常量池中也创建一个对象。他是放到堆内存中的,这里面可以有重复的,所以每一次创建都会new一个新的对象,所以他们的地址不同。
String 有一个intern() 方法,native,用来检测在String pool是否已经有这个String存在。
11.StringBuffer和StringBuilder的区别是什么?性能对比?如何鉴定线程安全?(高频考点)
基本对比:
- String:String对象是不可变的。"对String对象的任何改变都不影响到原对象,相关的任何change操作都会生成新的对象"。
- StringBuilder:StringBuilder是可变的,它不是线程安全的。
- StringBuffer:StringBuffer也是可变的,它是线程安全的,所以它的开销比StringBuilder大
使用时的建议:
- 循环外字符串拼接可以直接使用String的+操作,没有必要通过StringBuilder进行append.
- 有循环体的话,好的做法是在循环外声明StringBuilder对象,在循环内进行手动append。不论循环多少层都只有一个StringBuilder对象。
- 当字符串相加操作较多的情况下,建议使用StringBuilder,如果采用了多线程,则使用StringBuffer。
如何鉴定线程安全:
查看源代码便一目了然,事实上,StringBuilder和StringBuffer类拥有的成员属性以及成员方法基本相同,区别是StringBuffer类的成员方法前面多了一个关键字:synchronized,不用多说,这个关键字是在多线程访问时起到安全保护作用的,也就是说StringBuffer是线程安全的。
12.StringBuffer 和 StringBuilder 底层怎么实现的?(高频考点)
StringBuffer 和 StringBuilder 都是可变的字符串类,它们底层的实现方式略有不同:
StringBuffer
- StringBuffer 是线程安全的可变字符串类,它的底层实现使用字符数组(char[])来存储字符串内容。
- StringBuffer 内部维护了一个字符数组(value),以及表示字符串长度的变量(count)。
- 当进行字符串操作时,StringBuffer 会根据需要动态调整字符数组的大小,以容纳更多的字符。
- StringBuffer 的操作方法(如追加、插入、删除等)会对字符数组进行修改,并更新字符串的长度。
StringBuilder
- StringBuilder 也是可变字符串类,它与 StringBuffer 的区别在于线程安全性。
- StringBuilder 是非线程安全的,因此在多线程环境中使用时需要进行额外的同步措施。
- StringBuilder 的底层实现与 StringBuffer 类似,同样使用字符数组来存储字符串内容,并根据需要动态调整数组大小。
无论是 StringBuffer 还是 StringBuilder,它们的底层实现都是通过字符数组进行字符串的存储和操作。通过动态调整字符数组的大小,它们可以高效地进行字符串的修改和拼接操作。使用字符数组存储字符串内容的好处是可以避免频繁创建新的字符串对象,从而提高性能和内存利用率。
13.switch支持哪些数据类型?支持long么?(高频考点)
在 Java 中,switch
语句支持的数据类型有限,包括整型数据和枚举类型。具体来说,switch
语句支持的数据类型有:
- 整型数据类型:
byte
、short
、int
和char
。 - 枚举类型:自 Java 5 起,
switch
语句也支持枚举类型。
对于 long
类型数据,switch
语句是不支持的。如果需要在 switch
语句中使用 long
类型数据,可以考虑将其转换为 int
或 byte
类型,或者使用一系列的 if-else
语句来实现相同的逻辑。
14.创建一个类的实例都有哪些办法?(高频考点)
在 Java 中,创建一个类的实例(对象)有以下几种常见的方式:
-
使用
new
关键字: 最常见的创建对象的方式是使用new
关键字,通过调用类的构造方法来创建对象。例如:javaMyClass obj = new MyClass();
-
使用反射机制: Java 提供了反射机制,可以在运行时动态地创建对象。通过
Class
类的newInstance()
方法来创建对象。例如:javaClass<?> clazz = MyClass.class; MyClass obj = (MyClass) clazz.newInstance();
-
通过对象克隆: 如果一个类实现了
Cloneable
接口,并且重写了clone()
方法,那么可以通过对象的克隆来创建新的对象。例如:javaMyClass obj1 = new MyClass(); MyClass obj2 = (MyClass) obj1.clone();
-
通过反序列化: 可以将对象序列化为字节流,然后再反序列化为对象。通过
ObjectInputStream
类的readObject()
方法来创建对象。例如:javaFileInputStream fileIn = new FileInputStream("object.ser"); ObjectInputStream in = new ObjectInputStream(fileIn); MyClass obj = (MyClass) in.readObject();
-
使用工厂方法或者设计模式: 可以使用工厂方法模式、建造者模式等设计模式来创建对象,以封装对象的创建过程。例如:
javaMyClass obj = MyClassFactory.createInstance();
-
使用匿名类: 可以通过定义匿名类来创建对象,尤其在创建接口实例时较为常见。例如:
javaRunnable runnable = new Runnable() { public void run() { // 实现 run 方法 } };
以上是 Java 中创建对象的常见方式,可以根据具体的需求和场景选择合适的方式。
15.final、finnally、finalize的区别是什么?
final,finally,finalize之间长得像但一点关系都没有,仅仅是长的像!
final 表示不可修改的,可以用来修饰类,方法,变量。
- final修饰class表示该class不可以被继承。
- inal修饰方法表示方法不可以被overrride(重写)。
- final修饰变量表示变量是不可以修改。
- 一般来说推荐将本地变量,成员变量,固定的静态变量用final修饰,明确是不可以被修改的。
finally是Java的异常处理机制中的一部分。finally块的作用就是为了保证无论出现什么情况,finally块里的代码一定会被执行。
- 一般来说在try-catch-finally 来进行类似关闭 JDBC连接,释放锁等资源的操作。
- 如果try语句块里有return语句,那么finally还会被执行吗?答案是肯定的。
finalize是Object类的一个方法,是GC进行垃圾回收前要调用的一个方法。
- 如果实现了非空的这个方法,那么会导致相应对象回收呈现数量级上的变慢,在新版的JDK中(好像是1.9之后的版本),这个方法已经逐渐被抛弃了。
16.Jdk1.8/Jdk1.7都分别新增了哪些特性?其他版本呢?(高频考点)
Java 8新增特性:
- Lambda表达式:引入了函数式编程的概念,使得代码更简洁、可读性更高。
- Stream API:提供了一种更便利的处理集合数据的方式,支持并行处理。
- 默认方法(Default Methods):接口中可以定义默认实现,允许在接口中添加新方法而不破坏现有实现类的兼容性。
- 方法引用(Method References):可以通过方法的名字来引用已存在的方法。
- Optional类:提供了一种更好的处理可能为null的对象的方式,避免了空指针异常。
- 新的日期/时间API(java.time包):提供了更好的日期和时间处理方式,解决了旧的日期API的一些问题。
- CompletableFuture类:新增的异步编程工具,支持更方便地处理异步任务和回调。
Java 7新增特性:
- switch语句支持字符串类型:可以在switch语句中使用字符串进行比较。
- 泛型实例化类型自动推断:在创建泛型对象时,可以省略泛型类型的重复声明。
- try-with-resources语句:用于自动关闭实现了AutoCloseable接口的资源,避免了手动关闭资源的繁琐操作。
- 改进的类型推断:在实例化泛型对象时,编译器可以根据上下文推断出泛型的类型。
- 数字字面量下划线支持:可以在数字字面量中使用下划线分隔以提高可读性。
除了Java 8和Java 7,其他版本的Java也都引入了一些新特性和改进,其中一些主要的特性包括:
Java 9:
- 模块化系统(Project Jigsaw)
- JShell交互式解释器
- Reactive Streams API
- 改进的Stream API
- 私有接口方法
- 改进的垃圾收集器
Java 10:
- 局部变量类型推断
- 基于时间的版本号(Release Versioning)
- 并行全垃圾回收器
Java 11:
- HTTP Client API
- 局部变量语法增强
- ZGC垃圾回收器
- Epsilon垃圾收集器
Java 12:
- Switch表达式增强
- 新的垃圾收集器(Shenandoah)
- Microbenchmark Suite
Java 13:
- 文本块(Text Blocks)
- 动态CDS归档(Dynamic CDS Archives)
- ZGC并发压缩
Java 14:
- Switch表达式增强
- Pattern匹配
- 垃圾回收器改进
- Records(记录类)
Java 15:
- Sealed Classes(密封类)
- Text Blocks改进
- 垃圾回收器改进
- 隐藏类
Java 16:
- 隐藏类(Hidden Classes)
- Pattern匹配
- Records改进
- 新的垃圾回收器(ZGC并发压缩)
- UNIX套接字通道的改进
- 可见注释
Java 17:
- Sealed Classes(密封类)改进
- Pattern匹配增强
- 垃圾回收器改进
- 向后兼容性保持
- 升级Elasticsearch版本
Java 18(计划中):
- 目前尚未发布,具体特性尚未确定。
这些是Java 9到18版本的一些重要新增特性和改进。请注意,每个版本可能还包含了其他小的改进、修复和性能优化。建议参考官方文档和相关资源以获取更详细和全面的信息。
17.简单说下Lambda表达式,其解决了什么,相比java7的处理优化了什么?
Lambda 表达式是 Java 8 引入的一个重要特性,它提供了一种更简洁、更灵活的方式来编写匿名函数。Lambda 表达式的引入主要解决了以下两个问题,并在某些情况下优化了代码。
-
匿名内部类的冗余代码: 在 Java 7 及之前的版本中,要实现一个简单的功能,常常需要编写大量的匿名内部类。这些类会增加代码量并使代码显得冗余。Lambda 表达式通过简化匿名内部类的写法,让开发者能够更紧凑地表达逻辑,减少冗余代码。
-
代码可读性和可维护性: Lambda 表达式使代码更具可读性。通过将逻辑放在更接近使用它的地方,可以更清晰地传达代码的意图。这使得代码更易于理解和维护。
相比 Java 7 的方式,Lambda 表达式的引入在以下几个方面进行了优化:
-
简洁性: 使用 Lambda 表达式可以大大减少冗余的语法,让代码更加紧凑。特别是在处理集合、流式处理以及函数式编程方面,代码的可读性和简洁性得到了明显的提升。
-
迭代集合的优化: 在 Java 7 中,迭代集合需要通过 foreach 循环或迭代器来完成,而 Lambda 表达式和 Stream API 让集合的处理变得更加优雅,同时还能够自动利用多核处理器进行并行处理。
-
函数式编程: Lambda 表达式为 Java 引入了函数式编程的元素,使得在 Java 中更容易表达和使用函数式概念,如高阶函数、闭包等。
总之,Lambda 表达式的引入使 Java 编程更具现代化和函数式特性,使代码更具可读性、简洁性,同时提供了更好的性能优化和并行处理能力。这对于简化开发和编写高效代码都具有积极影响。
18.有人说"Lambda能让Java程序慢30倍",你怎么看?
在实际运行中,基于 Lambda/Stream 的版本(lambdaMaxInteger),比传统的 for-each 版本(forEachLoopMaxInteger)慢很多。
// 一个大的ArrayList,内部是随机的整形数据
volatile List<Integer> integers = ...
// 基准测试1
public int forEachLoopMaxInteger() {
int max = Integer.MIN_VALUE;
for (Integer n : integers) {
max = Integer.max(max, n);
}
return max;
}
// 基准测试2
public int lambdaMaxInteger() {
return integers.stream().reduce(Integer.MIN_VALUE, (a, b) -> Integer.max(a, b));
}
第一,基准测试是一个非常有效的通用手段,让我们以直观、量化的方式,判断程序在特定条件下的性能表现。
第二,基准测试必须明确定义自身的范围和目标,否则很有可能产生误导的结果。前面代码片段本身的逻辑就有瑕疵,更多的开销是源于自动装箱、拆箱(auto-boxing/unboxing),而不是源自 Lambda 和 Stream,所以得出的初始结论是没有说服力的。
第三,虽然 Lambda/Stream 为 Java 提供了强大的函数式编程能力,但是也需要正视其局限性:
- 一般来说,我们可以认为 Lambda/Stream 提供了与传统方式接近对等的性能,但是如果对于性能非常敏感,就不能完全忽视它在特定场景的性能差异了,例如:初始化的开销 。 Lambda 并不算是语法糖,而是一种新的工作机制,在首次调用时,JVM 需要为其构建CallSite实例。这意味着,如果 Java 应用启动过程引入了很多 Lambda 语句,会导致启动过程变慢。其实现特点决定了 JVM 对它的优化可能与传统方式存在差异。
- 增加了程序诊断等方面的复杂性,程序栈要复杂很多,Fluent 风格本身也不算是对于调试非常友好的结构,并且在可检查异常的处理方面也存在着局限性等。
19.SpI和API区别是什么?SpI底层实现是什么?
API(Application Programming Interface)是一组定义了程序之间如何交互的规则和协议,提供了访问和使用某个软件组件、库或服务的接口。API 描述了如何调用和使用已经存在的功能。开发者可以通过调用 API 中的函数、方法等来使用底层的功能,而不需要关心具体的实现细节。
SPI(Service Provider Interface)则是一种设计模式,它用于在软件中提供可扩展的功能实现。SPI 允许开发者在不修改核心代码的情况下,通过插件或扩展点来增加或替换功能的实现。在 SPI 中,核心代码定义了一组接口或抽象类,而实际的实现则由不同的服务提供者来提供。这种设计方式使得系统的扩展性更好,可以更容易地添加新的功能实现。
API(Application Programming Interface):
- 定义了如何与已经存在的功能或服务进行交互。
- 提供了使用已有功能的方法、函数、类等。
- 关注于如何正确地使用已有功能,而不关心实现细节。
- 使用 API 可以调用现有功能,但不可以随意添加新的实现。
SPI(Service Provider Interface):
- 是一种设计模式,用于实现插件化的扩展机制。
- 允许在不修改核心代码的情况下,通过插件添加或替换功能的实现。
- 定义了一组接口或抽象类,具体的实现由不同的服务提供者提供。
- 使用 SPI 可以动态地扩展和替换系统的功能实现。
在 Java 中,SPI 的底层实现通常是通过在 META-INF/services/
目录下创建配置文件,其中列出了实现了某个接口的类的全限定名。这些配置文件被加载器读取,以实现在运行时发现并加载不同的服务提供者。Java 标准库中的许多功能(如日志、数据库驱动、XML 解析器等)都使用了 SPI 设计模式来实现可扩展性。
20.深克隆和浅克隆?(考频不多)
深克隆(Deep Clone)和浅克隆(Shallow Clone)是针对对象克隆(Clone)操作的两种方式:
浅克隆(Shallow Clone):
- 浅克隆是指在克隆过程中,只复制对象本身和对象内部的基本数据类型字段,而不复制对象内部的引用类型字段。被复制的对象和原始对象中的引用类型字段将指向同一块内存地址。
- 如果原始对象中存在引用类型字段,那么浅克隆后的对象中的引用类型字段与原始对象中的引用类型字段指向相同的对象。
- 浅克隆的克隆对象和原始对象共享相同的引用对象,因此对克隆对象或原始对象的引用类型字段进行修改,会影响到另一个对象。
深克隆(Deep Clone):
- 深克隆是指在克隆过程中,不仅复制对象本身,还会递归复制对象内部的引用类型字段所引用的对象,直到所有相关的对象都被复制。
- 深克隆后的对象和原始对象是完全独立的,它们的内部引用对象是不同的,不会相互影响。
- 深克隆的克隆对象和原始对象之间不存在共享的引用对象,因此对克隆对象或原始对象的引用类型字段进行修改,不会影响到另一个对象。
浅克隆通常比深克隆操作快速和简单,但是在某些情况下可能会导致意外的行为,因为克隆对象和原始对象之间共享引用对象。在需要完全独立的对象副本时,深克隆是更可靠的选择。
21.伪共享机制简述分析
伪共享(False Sharing)是一种硬件和软件交互的现象,它可能对多线程程序的性能产生负面影响。
下面是对伪共享机制的简要分析:
**伪共享通常发生在多个线程同时访问不同但位于同一缓存行(Cache Line)的数据时。**缓存行是计算机内存中缓存的最小单位,通常是64字节。当多个线程同时修改或读取不同的数据,但这些数据位于同一缓存行时,就会引发伪共享问题。
当一个线程修改缓存行中的某个数据时,该缓存行会被标记为"脏",并且会将整个缓存行的数据刷新到主内存中。这将导致其他线程对于同一缓存行中的数据的缓存失效,即其他线程需要从主内存重新加载该缓存行的数据。这种缓存失效的频繁发生会导致性能下降。
伪共享问题的解决方案之一是通过对共享的数据进行填充(Padding),使得不同线程访问的数据分散到不同的缓存行上,从而避免了不必要的缓存行失效。填充可以通过在数据结构中添加额外的空间或使用特定的对齐方式来实现。
另一种解决伪共享问题的方法是使用缓存行对齐(Cache Line Alignment)技术。这种技术通过将数据结构的每个成员对齐到缓存行的边界,确保不同线程访问的数据位于不同的缓存行中,减少了缓存行的失效次数。
总而言之,伪共享是由于多个线程同时访问同一缓存行中不同数据而导致的性能问题。通过填充和缓存行对齐等技术,可以减少伪共享对多线程程序性能的影响,提高系统的并发性能。
22.假设引用了一个第三方的jar 有个类和我自己写的代码类一样,那么在类加载机制过程中是如何处理的?(高频考点)
当在类加载机制中遇到同名类的情况时,Java 虚拟机(JVM)会根据双亲委派模型来进行处理。这个模型要求除了顶层的启动类加载器(Bootstrap ClassLoader)外,每个类加载器在加载类时,首先委托其父类加载器进行加载,只有在父类加载器无法加载该类时,才由当前类加载器自行加载。
具体来说,如果在加载某个类时遇到同名类,JVM 会按照以下步骤进行处理:
-
委派给父类加载器: 当前类加载器会首先委派给父类加载器进行加载。父类加载器会按照双亲委派模型,先尝试从自己的缓存中查找已加载的类,如果找到了则直接返回;如果没有找到,则继续委派给其父类加载器加载。
-
依次向上委派: 类加载请求会依次向上委派,直到达到顶层的启动类加载器。如果所有父类加载器都无法加载该类,则当前类加载器会尝试自己加载该类。
-
本地加载: 当前类加载器在自己的类路径下查找并加载该类。如果找到了同名类,则直接加载;如果没有找到,则抛出类未找到异常(ClassNotFoundException)。
综上所述,如果第三方的 jar 包中包含了与自己代码中相同名称的类,首先会由系统类加载器或者扩展类加载器进行加载,只有当这两个类加载器都无法找到该类时,才会由自定义的类加载器加载。这样可以确保在运行时不会混淆相同名称的类。
23.Java提供了哪些IO方式? NIO如何实现多路复用?
Java IO 方式有很多种,基于不同的 IO 抽象模型和交互方式,可以进行简单区分。
第一,传统的 java.io 包,它基于流模型实现,提供了我们最熟知的一些 IO 功能,比如 File 抽象、输入输出流等。交互方式是同步、阻塞的方式,也就是说,在读取输入流或者写入输出流时,在读、写动作完成之前,线程会一直阻塞在那里,它们之间的调用是可靠的线性顺序。
java.io 包的好处是代码比较简单、直观,缺点则是 IO 效率和扩展性存在局限性,容易成为应用性能的瓶颈。
很多时候,人们也把 java.net 下面提供的部分网络 API,比如 Socket、ServerSocket、HttpURLConnection 也归类到同步阻塞 IO 类库,因为网络通信同样是 IO 行为。
第二,在 Java 1.4 中引入了 NIO 框架(java.nio 包),提供了 Channel、Selector、Buffer 等新的抽象,可以构建多路复用的、同步非阻塞 IO 程序,同时提供了更接近操作系统底层的高性能数据操作方式。
第三,在 Java 7 中,NIO 有了进一步的改进,也就是 NIO 2,引入了异步非阻塞 IO 方式,也有很多人叫它 AIO(Asynchronous IO)。异步 IO 操作基于事件和回调机制,可以简单理解为,应用操作直接返回,而不会阻塞在那里,当后台处理完成,操作系统会通知相应线程进行后续工作。
24.谈谈接口和抽象类有什么区别?
接口和抽象类是 Java 面向对象设计的两个基础机制。
接口是对行为的抽象,它是抽象方法的集合,利用接口可以达到 API 定义和实现分离的目的。接口,不能实例化;不能包含任何非常量成员,任何 field 都是隐含着 public static final 的意义;同时,没有非静态方法实现,也就是说要么是抽象方法,要么是静态方法。Java 标准类库中,定义了非常多的接口,比如 java.util.List。
抽象类是不能实例化的类,用 abstract 关键字修饰 class,其目的主要是代码重用。除了不能实例化,形式上和一般的 Java 类并没有太大区别,可以有一个或者多个抽象方法,也可以没有抽象方法。抽象类大多用于抽取相关 Java 类的共用方法实现或者是共同成员变量,然后通过继承的方式达到代码复用的目的。Java 标准库中,比如 collection 框架,很多通用部分就被抽取成为抽象类,例如 java.util.AbstractList。
Java 类实现 interface 使用 implements 关键词,继承 abstract class 则是使用 extends 关键词,我们可以参考 Java 标准库中的 ArrayList。
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
//...
}
(二)Java集合框架高频知识考点(高频考点集中地)
针对人员:
1.全部人员都适用;
2.正式员工:针对应届+工作1-3年的面试者,加大比重;3年以上的这部分可适量;
3.外包员工:本部分比重可减轻,但针对1-3年的需要适当增加比重;
1.HashMap相关put操作,get操作等流程?(高频考点)
以下回答进行简单简述如下。
当调用HashMap的put(key, value)方法时,会执行以下步骤:
- 计算键的哈希码:通过调用键对象的hashCode()方法来获取键的哈希码。
- 定位桶位置:将计算得到的哈希码映射到HashMap的内部数组中的一个桶(bucket)位置。使用哈希码与桶数量取模的方式来确定桶的位置。
- 查找或创建节点:在选定的桶位置上,遍历链表或红黑树(如果存在)以查找是否已存在具有相同键的节点。如果找到相同键的节点,则将新值替换旧值。如果未找到相同键的节点,则创建一个新节点。
- 插入或添加节点:将新节点插入到选定桶位置的链表或红黑树中。
- 判断是否需要调整容量:如果插入节点后,链表长度或红黑树的节点数量超过一定阈值(Java 8中为8),则会触发调整容量的操作,即扩容HashMap的内部数组。
当调用HashMap的get(key)方法时,会执行以下步骤:
- 计算键的哈希码:通过调用键对象的hashCode()方法来获取键的哈希码。
- 定位桶位置:将计算得到的哈希码映射到HashMap的内部数组中的一个桶位置。
- 查找节点:在选定的桶位置上,遍历链表或红黑树(如果存在),通过比较键的相等性(调用键对象的equals()方法)来查找具有相同键的节点。
- 返回节点值:如果找到具有相同键的节点,则返回该节点的值;否则,返回null表示未找到对应的值。
需要注意的是,HashMap使用哈希码和相等性比较来确定键值对的存储位置和查找操作。因此,在自定义对象作为键时,确保正确实现equals()和hashCode()方法非常重要,以保证HashMap的正确性和一致性。
我们重点还是分析put的内容,下图展开方便更深的理解:
2.Hash为啥要扩容
哈希表(Hash Table)在存储元素时使用哈希函数将元素的键映射到一个固定的数组位置上,这个数组被称为桶(bucket)。扩容是指在哈希表中的桶数量不足以容纳当前元素数量时,自动增加桶的数量。
哈希表扩容的主要目的是保持哈希表的负载因子(Load Factor)在一个合适的范围内。负载因子是指当前哈希表中存储元素的数量与桶的数量之比。
为什么需要控制负载因子呢?因为负载因子过高会导致哈希冲突的概率增加,即多个元素映射到同一个桶的可能性增大,进而降低哈希表的性能。通过扩容,可以增加桶的数量,从而降低负载因子,减少哈希冲突的发生,提高哈希表的效率和性能。
扩容的具体过程如下:
- 创建一个更大的桶数组,通常是原数组的两倍大小。
- 将原数组中的元素重新计算哈希值,并分配到新的桶中。
- 将元素存储到新的桶中。
- 最后,将新的桶数组替代原来的桶数组,完成扩容操作。
需要注意的是,哈希表的扩容是一项开销较大的操作,因为需要重新计算哈希值、重新分配桶,并且需要移动元素。为了减少频繁的扩容操作,通常在设计哈希表时会预估元素的数量,并根据预估值初始化合适大小的初始桶数组。此外,选择适当的负载因子阈值也是重要的,以平衡空间利用率和性能。
总结起来,哈希表扩容是为了保持合适的负载因子,减少哈希冲突,提高哈希表的性能和效率。
3.HashMap如果我想要让自己的Object作为K应该怎么办?
- 重写hashCode()是因为需要计算存储数据的存储位置,需要注意不要试图从散列码计算中排除掉一个对象的关键部分来提高性能,这样虽然能更快但可能会导致更多的Hash碰撞;
- 重写equals()方法,需要遵守自反性、对称性、传递性、一致性以及对于任何非null的引用值x,x.equals(null)必须返回false的这几个特性,目的是为了保证key在哈希表中的唯一性;
4.Hashmap 线程不安全的原因(高频考点)
HashMap
在多线程环境下可能会出现线程不安全的问题,主要原因包括以下几点:
-
非同步操作:
HashMap
是非同步的,即它不对多线程进行同步控制。在多线程并发操作的情况下,可能会导致竞争条件,从而引发不确定的行为。 -
并发扩容问题: 在
HashMap
进行扩容的时候,即在原有的数组上重新分配空间并重新计算哈希值,可能会导致多个线程同时修改HashMap
结构,从而破坏内部数据结构,引发异常或导致死循环。 -
链表成环问题: 在 JDK7 中,在多线程环境下,由于链表的头插法和并发扩容的原因,可能导致链表成环。当一个线程正在进行链表的迁移操作,另一个线程插入新节点时,可能造成链表成环,从而导致死循环。
-
并发操作引发的数据丢失问题: 当多个线程同时进行
put
操作时,可能会导致部分数据的丢失。例如,两个线程同时判断需要进行扩容,都计算了新的数组位置,然后分别在新位置进行插入操作,这样其中一个线程的插入操作会被覆盖,导致数据丢失。
为了解决以上问题,可以采取以下措施:
-
使用线程安全的集合类: 使用
Collections.synchronizedMap
或者ConcurrentHashMap
来替代普通的HashMap
,这两者都提供了一定程度的线程安全性。 -
手动同步控制: 在对
HashMap
进行操作时,使用显式的同步控制,例如使用synchronized
关键字,确保在多线程环境下对HashMap
的修改是同步的。 -
使用并发安全的Map实现:
ConcurrentHashMap
是 Java 提供的并发安全的 Map 实现,它在设计上避免了一些HashMap
中的问题,提供更好的并发性能。
5.HashMap1.7与HashMap1.8的区别,从数据结构上、Hash值的计算上、链表数据的插入方法、内部Entry类的实现上分析?
数据结构上
- JDK1.7的时候使用的是数组+ 单链表的数据结构。数组和链表节点的实现类是Entry类。
- 在JDK1.8及之后时,使用的是数组+链表+红黑树的数据结构(当链表的深度达到8的时候,也就是默认阈值,就会自动扩容把链表转成红黑树的数据结构来把时间复杂度从O(n)变成O(logN)提高了效率)。数组和链表节点的实现类是Node类。
Hash值的计算上
- JDK1.7用了9次扰动处理=4次位运算+5次异或
- JDK1.8只用了2次扰动处理=1次位运算+1次异或。直接用了JDK1.7的时候计算的规律,相当于只需要判断Hash值的新增参与运算的位是0还是1就直接迅速计算出了扩容后的储存方式。
链表数据的插入方法上
- JDK1.7用的是头插法,用单链表进行的纵向延伸,当采用头插法就是能够提高插入的效率,但是也会容易出现逆序且环形链表死循环问题。
- JDK1.8及之后使用的都是尾插法,因为加入了红黑树使用尾插法,能够避免出现逆序且链表死循环的问题。
内部Entry类的实现上
-
JDK1.7数组和链表节点的实现类是Entry类,实现了Map.entry接口。
javastatic class Entry<K,V> implements Map.Entry<K,V> { final K key; V value; Entry<K,V> next; int hash; }
-
JDK1.8数组和链表节点的实现类是Node类,但是还是实现了Map.entry接口。
javastatic class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; V value; Node<K,V> next; }
6.HashSet和HashMap区别
HashSet和HashMap是Java集合框架中的两个常用类,它们具有一些共同的特点,但也有一些区别。以下是对HashSet和HashMap的对比分析和一些建议:
共同点:
- 底层数据结构:HashSet和HashMap都使用哈希表作为其底层数据结构。它们都通过哈希码来确定元素的存储位置,从而实现快速的查找、插入和删除操作。
- 唯一性:HashSet和HashMap都保证元素的唯一性。在HashSet中,它保证集合中没有重复的元素;在HashMap中,它保证没有重复的键。
区别:
- 存储机制 :HashSet是基于HashMap实现的,但它只存储元素的键,值都被设置为同一个固定值(常量PRESENT)。因此,HashSet实际上是一个无序、不重复的集合;而HashMap是键值对的存储结构,可以存储键值对,并且键是唯一的。
- API:HashSet提供了Set接口的方法,而HashMap提供了Map接口的方法。因此,HashSet适合用于需要存储唯一元素的场景,而HashMap适合需要键值对映射关系的场景。
- 迭代顺序:由于HashSet是无序的,迭代元素的顺序是不确定的;而HashMap在迭代时,可以按照插入顺序或者根据键的哈希码顺序进行迭代(通过LinkedHashMap可以实现有序的遍历)。
使用建议:
-
如果只需要存储元素且不关心键值对: 使用
HashSet
,它提供了唯一性和集合操作。 -
如果需要存储键值对: 使用
HashMap
,它提供了键值对的存储和检索功能。 -
如果只关心键的唯一性: 如果只关心键的唯一性而不需要值,也可以使用
HashMap
并将值设为常量。 -
性能注意事项: 在需要频繁查找、插入、删除元素的场景下,
HashMap
通常更适用。但如果只关心唯一性、集合操作且不需要键值对关系时,HashSet
可能更简洁。
7.Hash1.7是基于数组和链表实现的,为什么不用双链表?HashMap1.8中引入红黑树的原因是?为什么要用红黑树而不是平衡二叉树?(高频考点)
- 在
HashMap
的早期版本(JDK7 及之前),使用的确实是单向链表而非双向链表。这是因为在实际的使用场景中,插入和删除节点时,只需要修改节点前后的指针即可,而不需要访问到当前节点的前一个节点。使用单链表可以降低节点的存储开销,因为不需要额外的指针指向前一个节点。 - 为了提高HashMap的性能,在解决发生哈希碰撞后,链表过长导致索引效率慢的问题,同时红黑树解决快速增删改查特点。
- 红黑树的平衡度相比平衡二叉树要低,对于删除、插入数据之后重新构造树的开销要比平衡二叉树低,查询效率比普通二叉树高,所以选择性能相对折中的红黑树。
8.HashMap、HashTable、ConcurrentHashMap的原理与区别?
以java7为背景情况下回答如下:
HashTable
- 底层数组+链表实现,无论key还是value都不能为null ,线程安全,实现线程安全的方式是在修改数据时锁住整个HashTable,效率低,ConcurrentHashMap做了相关优化
- 初始size为11,扩容:newsize = olesize*2+1
- 计算index的方法:index = (hash & 0x7FFFFFFF) % tab.length
HashMap
- 底层数组+链表实现,可以存储null键和null值 ,线程不安全
- 初始size为16,扩容:newsize = oldsize*2,size一定为2的n次幂
- 扩容针对整个Map,每次扩容时,原来数组中的元素依次重新计算存放位置,并重新插入
- 插入元素后才判断该不该扩容,有可能无效扩容(插入后如果扩容,如果没有再次插入,就会产生无效扩容)
- 当Map中元素总数超过Entry数组的75%,触发扩容操作,为了减少链表长度,元素分配更均匀
- 计算index方法:index = hash & (tab.length -- 1)
ConcurrentHashMap(简单简述java7的,java8见上面的讲解)
- 底层采用分段的数组+链表实现,线程安全
- 通过把整个Map分为N个Segment,可以提供相同的线程安全,但是效率提升N倍,默认提升16倍。(读操作不加锁,由于HashEntry的value变量是 volatile的,也能保证读取到最新的值。)
- Hashtable的synchronized是针对整张Hash表的,即每次锁住整张表让线程独占,ConcurrentHashMap允许多个修改操作并发进行,其关键在于使用了锁分离技术
- 有些方法需要跨段,比如size()和containsValue(),它们可能需要锁定整个表而而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁
- 扩容:段内扩容(段内元素超过该段对应Entry数组长度的75%触发扩容,不会对整个Map进行扩容),插入前检测需不需要扩容,有效避免无效扩容
注意,在 JDK8 中,HashMap
进行了一些优化,如引入红黑树、采用尾插法等。但在高并发环境下,ConcurrentHashMap
在设计上更注重并发性能, 能够更好地保持性能,并且它提供了更多的灵活性,允许部分并发读写操作。如果不需要并发操作,而且对性能要求不高,可以选择 HashMap
;如果需要线程安全,可以考虑 ConcurrentHashMap
或 HashTable
。
9.HashMap和ConcurrentHashMap区别(高频考点)
HashMap和ConcurrentHashMap是Java中的两种不同类型的映射(Map)实现。它们有以下几个区别:
- 线程安全性 :最显著的区别是HashMap是非线程安全的,而ConcurrentHashMap是线程安全的。在多线程环境下,多个线程可以同时访问和修改ConcurrentHashMap的不同部分,而不会导致数据不一致或其他并发问题。相反,如果多个线程同时访问和修改HashMap,则可能导致数据损坏或抛出ConcurrentModificationException等异常。
- 锁机制 :在Java 8之前的版本中,ConcurrentHashMap采用了分段锁的机制来实现线程安全。 每个段(Segment)都有自己的锁,可以独立地进行读写操作,从而提高并发性能。但在Java 8及以后的版本中,ConcurrentHashMap的内部结构发生了改变,采用了更为高效的实现方式。在Java 8及以后的版本中,ConcurrentHashMap使用了一种称为"分段锁升级"(Striped Locking)的机制。 它首先将数据分成一组小的桶(buckets),每个桶都可以独立地进行读写操作。每个桶中的元素可能对应多个键值对,但仍然保持并发安全。当多个线程同时访问ConcurrentHashMap时,会根据键的哈希值选择相应的桶,并使用Synchronized来锁定该桶。这样可以保证在同一个桶中的操作是互斥的,避免并发冲突。而在桶内部的读写操作,则使用了CAS操作来保证并发安全。**因此,结合Synchronized和CAS的机制,ConcurrentHashMap能够在并发环境中提供高效的线程安全性,允许多个线程同时读取和写入不同的桶,而不需要全局的锁。这种实现方式在性能和扩展性方面都有较好的表现。**而HashMap没有锁机制,所以在多线程环境下需要自行实现同步机制来确保线程安全。
- 迭代器:HashMap的迭代器(Iterator)是快速失败的(fail-fast),也就是说,如果在迭代过程中有其他线程修改了HashMap的结构(添加或删除元素),则会抛出ConcurrentModificationException异常。而ConcurrentHashMap的迭代器是弱一致性的(weakly consistent),不会抛出该异常,并且保证迭代器遍历期间能够看到最新的数据状态。
- 性能:由于ConcurrentHashMap使用了并发控制手段,它在高并发环境下能够提供更好的性能表现,允许多个线程同时读取和写入不同的段。相比之下,HashMap在并发情况下需要进行手动的同步操作,性能相对较低。
综上所述,如果需要在多线程环境下使用映射数据结构并且需要高并发性能,则应该使用ConcurrentHashMap。而在单线程环境下或者不需要并发安全的情况下,HashMap是更简单、更高效的选择。
当选择使用HashMap或ConcurrentHashMap时,以下是一些建议:
使用HashMap:
- 在单线程环境下,或者在不需要并发安全性的情况下,使用HashMap是简单和高效的选择。
- 当只有一个线程对Map进行读写操作时,HashMap通常比ConcurrentHashMap性能更好,因为它不需要进行额外的并发控制。
使用ConcurrentHashMap:
- 在多线程环境下,特别是需要高并发性能和线程安全性的情况下,应使用ConcurrentHashMap。
- 当多个线程需要并发读写Map时,ConcurrentHashMap能够提供更好的性能,因为它使用了分段锁(在Java 7及之前的版本)或基于结合Synchronized和CAS的机制算法(在Java 8及之后的版本)来实现并发控制。
- 当需要在遍历ConcurrentHashMap时,它的迭代器提供弱一致性保证,不会抛出ConcurrentModificationException异常,因此更适合在并发环境中进行遍历操作。
总结:
- 如果在单线程环境下,或者不需要并发安全性,使用HashMap。
- 如果在多线程环境下,特别是需要高并发性能和线程安全性,使用ConcurrentHashMap。
10. ConcurrentHashMap的数据结构(高频考点)
在JDK1.7版本中,ConcurrentHashMap维护了一个Segment数组,Segment这个类继承了重入锁ReentrantLock,并且该类里面维护了一个 HashEntry<K,V>[] table数组,在写操作put,remove,扩容的时候,会对Segment加锁,所以仅仅影响这个Segment,不同的Segment还是可以并发的,所以解决了线程的安全问题,同时又采用了分段锁也提升了并发的效率。
在JDK1.8版本中,ConcurrentHashMap摒弃了Segment的概念,而是直接用Node数组+链表+红黑树的数据结构来实现,并发控制使用Synchronized和CAS来操作,整个看起来就像是优化过且线程安全的HashMap。
在JDK1.8版本中,对于size的计算,在扩容和addCount()时已经在处理了。JDK1.7是在调用时才去计算。
11.高并发HashMap的环是如何产生的(高频考点)
重点该问题产生于jdk7中(jdk8已经解决了"环形链表",其采用了尾插法而非反转链表的方式),HashMap成环原因的代码出现在transfer代码中,也就是扩容之后的数据迁移部分,代码如下:
java
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
解释一下transfer的过程:首先获取新表的长度,之后遍历新表的每一个entry,然后每个ertry中的链表以反转的形式形成rehash之后的链表。
并发问题:若当前线程一此时获得entry节点,但是被线程中断无法继续执行,此时线程二进入transfer函数,并把函数顺利执行,此时新表中的某个位置有了节点,之后线程一获得执行权继续执行,因为并发transfer,所以两者都是扩容的同一个链表,当线程一执行到e.next = new table[i] 的时候,由于线程二之前数据迁移的原因导致此时new table[i] 上就有ertry存在,所以线程一执行的时候,会将next节点,设置为自己,导致自己互相使用next引用对方,因此产生链表,导致死循环。
解决问题:
- 使用synchronize
- 使用collection.synchronizeXXX方法
- 使用concurrentHashmap来解决。
12.哪些集合是线程安全的?
线程安全的集合类通常在 java.util.concurrent
包下,以下是几种常见的线程安全的集合类:
-
ConcurrentHashMap
: 用于代替Hashtable
,它提供了线程安全的键值对存储,并且性能比Hashtable
更好。 -
CopyOnWriteArrayList
和CopyOnWriteArraySet
: 这两个类提供了线程安全的动态数组和集合,它们在遍历操作频繁而修改操作较少的情况下性能很好。 -
ConcurrentLinkedQueue
和ConcurrentLinkedDeque
: 这两个类提供了线程安全的队列和双端队列的实现。 -
BlockingQueue
接口的实现类:ArrayBlockingQueue
、LinkedBlockingQueue
、PriorityBlockingQueue
等,它们提供了阻塞队列的实现,可以在多线程环境中安全地进行数据交换。 -
ConcurrentSkipListMap
和ConcurrentSkipListSet
: 这两个类提供了线程安全的有序映射和有序集合的实现,基于跳表的数据结构实现。
这些线程安全的集合类可以在多线程环境中安全地使用,不需要额外的同步措施。然而,需要注意的是,虽然这些集合类提供了线程安全的操作,但并不意味着它们可以完全替代同步措施,有时仍然需要在多线程访问时进行额外的同步操作。
13.Collections.SynchronizedCollection方法实现原理是什么?
Collections.synchronizedCollection(Collection<T> c)
方法的实现原理主要涉及以下几个方面:
-
包装原始集合: 方法会创建一个
SynchronizedCollection
对象作为包装器,其中内部持有传入的原始集合c
。 -
同步化操作: 在
SynchronizedCollection
类中,对于所有会修改集合状态的操作(例如添加、删除元素),都会使用synchronized
关键字修饰,以确保在多线程环境下的线程安全性。通过对mutex
对象进行同步化,实现了对原始集合的同步化操作。 -
锁对象:
SynchronizedCollection
类中定义了一个mutex
对象作为同步锁。这个锁对象通常是this
或者SynchronizedCollection
对象本身。使用synchronized
块来锁定这个锁对象,以确保对原始集合的操作是互斥的,从而保证了线程安全性。 -
透明性: 返回的
SynchronizedCollection
对象对外部用户来说是透明的,用户不需要知道内部实现的细节,只需要知道它是一个线程安全的集合。这种透明性使得用户可以像操作普通集合一样操作线程安全集合,提高了使用的便捷性。
Collections.synchronizedCollection(Collection<T> c)
方法通过创建一个包装器类,使用同步化的方式对原始集合的操作进行处理,从而实现了对传入集合的线程安全封装。
14.Array和ArrayList有什么区别?使用时注意事项有哪些?
Array和ArrayList是Java中用于存储和操作多个元素的数据结构,它们有一些区别和使用时需要注意的事项。
固定大小 vs 动态大小:
- Array(数组)具有固定的大小,一旦创建后大小不可变。
- ArrayList是基于数组实现的动态大小的容器,可以根据需要自动调整大小。
类型限制:
- Array可以存储任何类型的元素,包括基本类型和引用类型。
- ArrayList只能存储引用类型的元素,不能直接存储基本类型,需要使用其对应的包装类。
增删元素:
- Array的大小固定,无法直接增加或删除元素。可以通过创建新的Array并复制元素来模拟增删操作。
- ArrayList提供了方便的方法来添加(add())和删除(remove())元素,可以动态调整大小。
遍历:
- Array可以使用简单的for循环或增强for循环遍历。
- ArrayList同样可以使用for循环或增强for循环遍历,也可以使用迭代器(Iterator)进行遍历。
性能:
- Array的访问速度较快,因为元素在内存中是连续存储的。
- ArrayList的访问速度相对较慢,因为需要通过索引计算元素位置。
注意事项:
- Array在创建时需要指定大小,并且大小不能改变,如果需要动态调整大小,需要手动操作。
- ArrayList在使用时可以根据需要动态调整大小,无需手动处理大小问题。
- 使用Array时,需要手动处理增删元素和数组大小的维护。
- ArrayList是线程不安全的,如果在多线程环境下使用,需要进行适当的同步或使用线程安全的替代类(如Vector、CopyOnWriteArrayList等)。
- Array可以直接存储基本类型的元素,而ArrayList需要使用对应的包装类作为元素类型。
- Array在创建时需要明确指定元素类型和大小,而ArrayList在创建时无需指定大小,可以根据需要动态扩展。
根据具体的需求和场景,选择适合的数据结构。如果需要灵活的大小调整和内置操作方法,可以使用ArrayList;如果需要更高的性能和直接的内存访问,可以使用Array。
15.常用的集合类有哪些?比如List如何排序(最好说下底层上的实现)?(高频考点)
List 接口的实现类:
- ArrayList:基于数组实现的动态数组,支持快速随机访问元素。
- LinkedList:基于双向链表实现的列表,支持快速插入和删除操作。
- Vector:线程安全的动态数组,性能较 ArrayList 差,不推荐使用。
Set 接口的实现类:
- HashSet:基于哈希表实现的集合,不保证元素的顺序。
- TreeSet:基于红黑树实现的有序集合,元素按照自然顺序或者指定比较器的顺序进行排序。
- LinkedHashSet:基于哈希表和链表实现的集合,元素按照插入顺序排序。
Map 接口的实现类:
- HashMap:基于哈希表实现的键值对映射,不保证元素的顺序。
- TreeMap:基于红黑树实现的有序键值对映射,键按照自然顺序或者指定比较器的顺序进行排序。
- LinkedHashMap:基于哈希表和链表实现的键值对映射,元素按照插入顺序排序。
对于 List 接口的实现类,可以使用 Collections 类的静态方法 sort()
来排序。如果需要自定义排序规则,可以传入一个 Comparator 对象给 sort()
方法。List 排序的底层原理取决于具体使用的排序算法。在 Java 中,Collections.sort()
方法使用了归并排序(Merge Sort)或者快速排序(Quick Sort)算法来对 List 进行排序。
归并排序(Merge Sort):
- 归并排序是一种稳定的排序算法,它将待排序的 List 不断分割为更小的子序列,直到每个子序列只有一个元素,然后将这些子序列两两合并,直到整个 List 排序完成。
- 归并排序的时间复杂度为 O(nlogn),空间复杂度为 O(n),适用于大规模数据和外部排序。
快速排序(Quick Sort):
- 快速排序是一种不稳定的排序算法,它通过选择一个基准元素,将 List 分割为两个子序列,其中一个子序列的元素都小于基准元素,另一个子序列的元素都大于基准元素,然后对这两个子序列分别递归进行快速排序。
- 快速排序的时间复杂度为 O(nlogn),空间复杂度为 O(logn),适用于大规模数据但可能会因为基准选择不当而导致性能下降。
在具体实现中,Collections.sort()
方法的具体实现会根据 List 的大小和元素类型选择合适的排序算法。通常情况下,对于小规模的 List,会使用插入排序(Insertion Sort)或者二分插入排序(Binary Insertion Sort)来进行排序,因为这些排序算法在小规模数据上有较好的性能表现。而对于大规模的 List,则会使用归并排序(Merge Sort)或者快速排序(Quick Sort)来进行排序,因为这些排序算法在大规模数据上有较好的性能表现。
16.ArrayList和LinkedList内部的实现大致是怎样的?他们之间的区别和各自适应的场景是什么?
ArrayList 和 LinkedList 是 Java 中常见的两种 List 实现,它们的内部实现有所不同,适用于不同的场景。
ArrayList 内部实现:
- ArrayList 基于数组实现,内部维护一个 Object 数组作为数据存储。
- 当向 ArrayList 中添加元素时,如果数组容量不足,则会创建一个新的数组,并将原数组中的元素复制到新数组中,然后将新元素添加到新数组中。
- ArrayList 支持随机访问(通过索引直接访问元素),因为数组支持快速随机访问。
- 由于底层是数组实现,ArrayList 在随机访问和修改元素时具有较好的性能,但在插入和删除元素时性能相对较差,因为需要移动后续元素。
LinkedList 内部实现:
- LinkedList 基于双向链表实现,每个节点包含了元素值以及指向前后节点的引用。
- 当向 LinkedList 中添加元素时,只需简单地调整节点的引用关系,不需要像 ArrayList 那样复制数组。
- LinkedList 支持快速插入和删除操作,因为只需要调整节点的引用关系,而不涉及元素的移动。
- 由于底层是链表实现,LinkedList 在随机访问时性能较差,因为需要遍历链表来找到指定位置的元素。
区别和适应场景:
- ArrayList 适用于需要快速随机访问元素的场景,例如经常需要根据索引来获取或修改元素的场景。
- LinkedList 适用于需要频繁插入、删除元素的场景,例如经常需要在列表的中间插入或删除元素的场景。
- 如果只需在列表的末尾进行添加或删除操作,并且不需要频繁的随机访问元素,则两者性能差异不大,可以根据具体情况选择合适的实现。
总的来说,ArrayList 的优势在于快速随机访问,而 LinkedList 的优势在于快速插入和删除。根据实际需求选择合适的实现,以获得更好的性能和效率。
(三)多线程与并发编程高频知识考点(高频考点集中地)
针对人员:
1.全部人员都适用;
2.正式员工:针对应届+工作1-3年的面试者,减少比重;3年以上的这部分可加大比重;
3.外包员工:本部分比重可减轻,但针对1-3年的需要适当增加比重;3年以上的必考;
1.进程和线程的区别,进程间如何通讯,线程间如何通讯?
进程和线程是操作系统中的两个重要概念,它们有以下区别:
- 定义:进程是操作系统分配资源的基本单位,是一个正在执行的程序的实例。线程是进程内的一个执行单元,是进程的实际运行单位。
- 资源占用:每个进程都有独立的内存空间、文件句柄、打开的文件等系统资源,进程之间的资源相互隔离。而线程是在进程内共享进程的资源,包括内存空间、文件句柄等。多个线程共享同一进程的资源,因此线程之间的通信和数据共享更为方便。
- 调度和切换:进程拥有自己的执行状态、程序计数器、栈等信息,需要操作系统进行进程切换和调度。而线程作为进程内的执行单元,由操作系统进行线程切换和调度。线程切换开销较小,因为线程共享相同的地址空间,切换时只需保存和恢复寄存器状态即可。
- 执行关系:一个程序至少包含一个进程,而进程可以包含多个线程。进程是多个线程的容器,线程是进程的实际执行单位。
- 并发性:多个进程之间可以并发执行,每个进程都有自己的一组线程。而线程是轻量级的执行单位,线程之间可以并发执行,一个进程的多个线程可以在不同的处理器上并行执行。
- 影响:进程的创建和销毁都需要较大的系统开销,包括分配内存空间、建立数据结构等。而线程的创建和销毁开销较小。
总结来说,进程和线程是操作系统中的两个基本概念,进程是资源分配的单位,而线程是执行的单位。进程之间相互独立,线程在同一进程内共享资源。线程切换开销小,可以实现更高效的并发执行。在设计和开发应用程序时,需要根据具体需求和系统架构选择适合的进程和线程模型。
进程间通信(Inter-Process Communication,IPC)和线程间通信(Inter-Thread Communication,ITC)是实现进程或线程之间数据交换和信息共享的方式。在操作系统中,进程间通信和线程间通信通常采用以下方式:
进程间通信(IPC):
- 管道(Pipe)
- 命名管道(Named Pipe,FIFO)
- 信号(Signal)
- 消息队列(Message Queue)
- 共享内存(Shared Memory)
- 套接字(Socket)
线程间通信(ITC):
- 共享内存(Shared Memory)
- 信号量(Semaphore)
- 互斥锁(Mutex)
- 条件变量(Condition Variable)
- 信号(Signal)
- 屏障(Barrier)
- 队列(Queue)
通过这些通信方式,进程间或线程间可以进行数据交换、同步操作等。选择合适的通信方式取决于具体的需求和场景。
2.Java中线程之间如何通信
在Java中,线程之间可以通过以下几种方式进行通信:
- 共享变量 :线程之间可以通过共享变量来进行通信。多个线程可以访问和修改同一个共享变量,通过读取和修改共享变量的值来进行信息交换。需要注意的是,当多个线程同时访问共享变量时,需要保证线程安全,可以使用锁或其他同步机制来实现。
- 管道(Pipe):管道是一种半双工的通信方式,其中一个线程通过输出流将数据发送到管道,另一个线程通过输入流从管道中读取数据。管道可以用于在两个线程之间传递数据。
- 阻塞队列(Blocking Queue):阻塞队列是一种线程安全的数据结构,它提供了线程之间安全的数据交换。一个线程可以将数据放入阻塞队列的尾部,而另一个线程可以从队列的头部获取数据。当队列为空时,获取操作会被阻塞,直到有数据可用;当队列已满时,插入操作会被阻塞,直到有空间可用。
- wait/notify机制:通过调用对象的wait()方法,线程可以进入等待状态,释放对象的锁,并等待其他线程的通知。另一个线程可以通过调用对象的notify()或notifyAll()方法来唤醒等待的线程。这种方式常用于实现线程之间的协调与同步。
- Condition条件:java.util.concurrent.locks.Condition接口提供了线程间通信的高级方式。可以通过Condition对象与锁(例如ReentrantLock)结合使用,实现更灵活的线程间通信。线程可以通过调用await()方法进入等待状态,通过调用signal()或signalAll()方法来唤醒等待的线程。
这些是常见的线程间通信方式,具体的选择取决于场景和需求。需要根据具体情况选择合适的通信方式,并使用正确的同步机制来保证线程间的安全性和可靠性。
3.并发和并行的区别
并发(Concurrency)和并行(Parallelism)是计算机领域中两个相关但不同的概念:
并发(Concurrency)
并发指的是在同一时间段内执行多个任务或处理多个事件。它强调多个任务之间的交替执行和共享资源的竞争。在并发情况下,多个任务通过快速的切换,使得它们似乎是同时执行的。并发可以提高系统的吞吐量和资源利用率,并改善响应时间。
并行(Parallelism)
并行指的是同时执行多个任务或处理多个事件。在并行情况下,多个任务真正地同时执行,每个任务占用不同的物理处理器核心或计算资源。并行利用了多核处理器或分布式系统的优势,通过同时处理多个任务来提高系统的处理能力和性能。
总结来说:
- 并发是在同一时间段内执行多个任务或处理多个事件,强调任务之间的交替执行和资源竞争。
- 并行是真正同时执行多个任务或处理多个事件,利用多核处理器或分布式系统的能力,提高处理能力和性能。
可以将并发视为一种逻辑上的概念,强调任务之间的关系和调度方式,而并行则是一种物理上的概念,强调任务的同时执行。
在实际应用中,通过并发和并行的技术可以提高系统的性能和响应能力。例如,通过多线程实现并发处理、利用多核处理器实现并行计算、使用分布式系统实现并行处理等。
4.Java线程的状态?细说一下BLOCKED和WAITING有什么区别?(高频考点)
Java 线程的状态可以分为以下几种:
-
新建(New): 线程对象被创建但还未启动时的状态。此时线程对象已经被创建,但是还没有调用
start()
方法启动线程。 -
就绪(Runnable): 线程对象调用了
start()
方法后,线程处于就绪状态。此时线程已经准备好运行,但是还未获得 CPU 执行时间。 -
运行(Running): 线程获取到 CPU 执行时间,开始执行任务的状态。处于运行状态的线程正在执行任务代码。
-
阻塞(Blocked): 线程因为某些原因暂时无法执行任务而被阻塞的状态。常见的情况包括等待某个资源(如锁)、等待输入输出操作完成、等待其他线程执行完毕等。
-
等待(Waiting): 线程调用了
wait()
方法后进入等待状态。在等待状态下,线程会等待其他线程调用notify()
或notifyAll()
方法来唤醒它。 -
超时等待(Timed Waiting): 线程调用了
sleep()
、join()
或LockSupport.parkNanos()
等方法,并设置了等待时间,线程会进入超时等待状态。在超时等待状态下,线程会等待指定的时间,如果时间到了仍未被唤醒,线程会自动唤醒并进入就绪状态。 -
终止(Terminated): 线程执行完任务或者因异常而终止时的状态。处于终止状态的线程不会再执行任务,线程对象也会被销毁。
这些状态在 Java 线程的生命周期中是动态变化的,线程会根据不同的情况在各个状态之间转换。
BLOCKED
(阻塞)和 WAITING
(等待)是 Java 线程状态中的两种不同状态,它们之间有以下区别:
BLOCKED(阻塞):
- 线程处于
BLOCKED
状态通常是因为等待某个锁的释放而被阻塞。 - 当一个线程在等待进入同步代码块或方法时,如果这个同步块或方法已经被其他线程占用,则当前线程会被阻塞,进入
BLOCKED
状态。 - 线程在
BLOCKED
状态下,会等待其他线程释放锁资源,以便获取锁并继续执行任务。 - 典型的场景包括线程竞争同步锁,等待其他线程释放锁资源。
WAITING(等待):
- 线程处于
WAITING
状态通常是因为需要等待特定的条件才能继续执行,而不是等待锁的释放。 - 当线程调用
Object.wait()
、Thread.join()
、LockSupport.park()
等方法时,会进入WAITING
状态。 - 线程在
WAITING
状态下,会一直等待特定条件的出现或者其他线程的唤醒。 - 典型的场景包括线程调用
wait()
方法等待其他线程的通知,或者调用join()
方法等待指定线程执行完毕。
总的来说,BLOCKED
状态是因为等待锁资源而被阻塞,而 WAITING
状态是因为等待特定条件或其他线程的唤醒而被阻塞。两者的区别在于等待的对象不同,导致了不同的线程状态。
5.Java实现多线程的方式有哪些?(高频考点)
在 Java 中,有多种方式可以实现多线程。以下是一些常见的方法:
1.继承 Thread 类
- 创建一个类继承自
Thread
类。 - 重写
run
方法,在run
方法中定义线程执行的任务。 - 创建该类的对象,调用
start
方法启动线程。
2.实现 Runnable 接口:
- 创建一个类实现
Runnable
接口。 - 实现
run
方法,定义线程执行的任务。 - 创建该类的对象,并将其作为参数传递给
Thread
类的构造方法。 - 调用
start
方法启动线程。
3.使用匿名类:
- 创建一个匿名类,继承
Thread
类或实现Runnable
接口。 - 重写
run
方法,定义线程执行的任务。 - 创建匿名类的对象,并调用
start
方法。
4.使用 Callable 和 Future:
- 创建一个实现
Callable
接口的类。 - 实现
call
方法,定义线程执行的任务,并返回结果。 - 使用
ExecutorService
提交Callable
对象,得到Future
对象,通过Future
可以获取线程的执行结果。
5.使用 Executor 框架:
- 使用
Executor
框架提供的线程池。 - 创建一个实现
Runnable
接口或Callable
接口的类。 - 将任务提交给
Executor
。
这些是 Java 中常用的多线程实现方式。选择合适的方式取决于任务的性质和对线程的管理需求。
6.Java处理多线程的方式有哪些?
Java处理多线程的方式有以下几种:
- 同步机制:使用关键字synchronized或使用Lock接口及其实现类(如ReentrantLock)进行线程同步。这可以确保多个线程之间的访问顺序和互斥访问共享资源,避免数据竞争和不一致的结果。
- 线程通信:使用等待/通知机制实现线程之间的协调和通信。可以使用wait()、notify()和notifyAll()方法在多个线程之间进行信号的发送和接收。
- 线程安全的容器:Java提供了一些线程安全的容器类,如Vector、ConcurrentHashMap、ConcurrentLinkedQueue等。这些容器类在多线程环境下提供了并发访问的安全性。
- 原子类:Java提供了一些原子类(Atomic classes),如AtomicInteger、AtomicLong、AtomicReference等。这些类提供了原子操作,可以保证操作的原子性,避免了线程间的竞争条件。
- 线程池:使用线程池管理和调度线程的执行。可以通过Executors类创建线程池,然后提交任务给线程池执行。线程池提供了线程的复用、线程数量的控制以及任务队列等功能。
- 并发工具类:Java提供了一些并发工具类,如CountDownLatch、CyclicBarrier、Semaphore等,用于多线程间的协作和控制。
- 并发框架:Java提供了并发框架,如Java 8引入的CompletableFuture和Stream API,可以简化多线程编程,实现异步和并行操作。
这些方式可以帮助处理多线程编程中的并发和同步问题,提高程序的性能和可靠性。具体使用哪种方式取决于具体的需求和场景。
7.Java程序中启动一个线程是用run()还是start()?
在 Java 中,启动一个线程应该使用 start()
方法,而不是直接调用 run()
方法。
- 使用
start()
方法启动一个线程会创建一个新的线程,并在新线程中执行run()
方法的内容。这样做会实现多线程并发执行的效果。 - 直接调用
run()
方法只会在当前线程中执行run()
方法的内容,并不会创建新的线程。这样做并不会实现多线程的效果,只是简单地执行了一个方法而已。
因此,如果希望实现多线程并发执行的效果,应该调用 start()
方法来启动线程。
8.Thread的start方法调用两次会怎么样?Thread是如何保证start方法调用只有一次生效?(高频考点)
Thread 的 start 方法调用两次会怎么样?
如果 Thread
的 start()
方法被调用两次,第二次调用会导致 IllegalThreadStateException
异常被抛出。这是因为 Thread
类内部维护了一个状态机,用来标识线程的状态。在调用 start()
方法时,会检查线程状态是否处于新建状态(即线程还未启动)。如果线程处于新建状态,则可以启动线程执行并将状态转换为就绪状态;如果线程不处于新建状态(例如已经处于就绪状态、运行状态等),再次调用 start()
方法就会抛出 IllegalThreadStateException
异常,因为线程已经启动或正在执行,无法重新启动。
Thread 的 start 方法调用只有一次生效的原因?
start()
方法是 Thread
类的一个同步方法,内部使用了synchronized
来确保线程状态转换的原子性。这样可以防止多个线程同时调用 start()
方法导致的竞态条件问题。当一个线程调用 start()
方法时,会获取 Thread
对象的锁,执行线程状态转换的过程,如果另一个线程尝试再次调用 start()
方法,由于该方法被 synchronized
修饰,需要等待前一个线程释放锁才能执行,因此确保了 start()
方法只能被调用一次生效的特性。
9.什么是守护线程?有什么用?
在 Java 中,守护线程(Daemon Thread)是一种特殊类型的线程,其特点是当所有非守护线程结束时,守护线程会自动结束,从而随着 JVM 的退出而结束。守护线程与普通线程的区别在于它们的生命周期不会影响 JVM 的终止。守护线程的特点包括:
-
在后台运行: 守护线程通常用于执行一些后台任务,不需要用户主动控制的工作。它们在后台默默地运行,不会干扰到用户主线程的执行。
-
随着 JVM 的终止而结束: 当所有非守护线程结束时,JVM 会自动关闭,守护线程也会随之结束。这样可以避免守护线程继续运行导致 JVM 无法正常退出的问题。
-
不影响 JVM 的终止: 守护线程的生命周期不会影响 JVM 的终止。当所有非守护线程结束时,JVM 会检查是否还有守护线程在运行,如果没有,则会正常退出;如果有,则会强制结束所有守护线程并退出。
守护线程通常用于执行一些后台任务,例如垃圾回收器(Garbage Collector)就是一个典型的守护线程。垃圾回收器在后台不断地回收无用的内存资源,以便释放内存空间,但它并不需要用户主动控制,而是由 JVM 自动管理。
总的来说,守护线程的作用是在后台执行一些不需要用户主动控制的任务,它们的生命周期不会影响 JVM 的终止,可以提高系统的稳定性和可靠性。
10.两个线程如何串行执行?
要实现两个线程的串行执行,可以使用线程间的协调机制来控制它们的执行顺序。以下是几种常见的方法:
-
使用 join() 方法: 在一个线程中调用另一个线程的
join()
方法,会等待该线程执行完成后再继续执行当前线程。通过这种方式,可以实现两个线程的串行执行。例如:javaThread thread1 = new Thread(new MyTask1()); Thread thread2 = new Thread(new MyTask2()); thread1.start(); thread1.join(); // 等待 thread1 执行完成 thread2.start(); // thread2 在 thread1 执行完成后再启动
-
使用 wait() 和 notify() 方法: 可以在一个线程中使用
wait()
方法使其进入等待状态,然后在另一个线程中使用notify()
或notifyAll()
方法来唤醒等待的线程。通过这种方式,可以实现两个线程的串行执行。例如:javaObject lock = new Object(); // 线程1 synchronized (lock) { // 执行线程1的任务 // 线程1执行完成后,唤醒等待的线程 lock.notify(); } // 线程2 synchronized (lock) { // 等待线程1执行完成 lock.wait(); // 执行线程2的任务 }
-
使用同步方法或同步代码块: 可以使用 synchronized 关键字来保证多个线程对共享资源的访问是同步的,从而实现线程的串行执行。例如:
javasynchronized void thread1Task() { // 线程1的任务 } synchronized void thread2Task() { // 线程2的任务 }
通过以上方式,可以实现两个线程的串行执行,确保它们按照指定的顺序执行。选择合适的方法取决于具体的场景和需求。
11.可以运行时kill掉一个线程吗?
在 Java 中,没有直接的方法可以在运行时"杀死"一个线程,也没有提供类似于 Thread.kill()
的方法。这是因为在 Java 中,线程的停止是基于协作和共享状态的,而不是通过直接终止线程的方式。
然而,你可以通过设置一个标志位,让线程在下一个合适的时机自行停止。例如,你可以使用一个 volatile boolean
类型的标志位,在线程执行的过程中定期检查该标志位,并在标志位变为 true
时自行停止线程。
下面是一个简单的示例:
java
public class MyThread extends Thread {
private volatile boolean running = true;
public void stopThread() {
running = false;
}
@Override
public void run() {
while (running) {
// 线程执行的任务
System.out.println("Thread is running...");
try {
Thread.sleep(1000); // 模拟线程执行任务的时间
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("Thread stopped.");
}
public static void main(String[] args) throws InterruptedException {
MyThread thread = new MyThread();
thread.start();
Thread.sleep(5000); // 等待一段时间
thread.stopThread(); // 设置标志位,停止线程
}
}
12.Synchronized的实现原理(高频考点)
Synchronized
是 Java 中用于实现同步的关键字,它可以被用于方法、代码块、以及实例方法和静态方法。Synchronized
的主要目的是控制多个线程访问共享资源时的并发问题,确保线程之间的协调执行。在 Java 中,Synchronized
的实现原理主要基于对象头的 Mark Word 和 Monitor(监视器)。
基于对象头的 Mark Word
-
对象头: 在 Java 对象的内存布局中,对象头包含了一些用于存储对象自身的运行时数据,其中的 Mark Word 就是其中的一部分。
-
Mark Word: Mark Word 存储了对象的 hashCode、分代年龄、锁标志等信息。其中,
Synchronized
使用了 Mark Word 中的锁标志位来实现同步。
Monitor(监视器)
-
每个 Java 对象都与一个 Monitor 关联,用于实现对象级别的同步。
-
Monitor 中有两个队列:
- EntryList: 存储等待获取锁的线程。
- WaitSet: 存储因为某些原因(例如调用了
Object.wait()
方法)而被挂起的线程。
Synchronized 实现原理
-
进入同步块: 当一个线程尝试进入一个同步代码块时,会首先尝试获取对象的锁。
-
**锁的获取:**如果对象的 Mark Word 中的锁标志位为 0,表示该对象没有被锁定,那么线程将尝试获取锁,并将锁标志位设置为线程的 ID。如果对象已经被其他线程锁定,那么当前线程会进入 EntryList 队列等待。
-
**锁的释放:**当线程退出同步块时,会释放对象的锁,将锁标志位清零。如果有其他线程在 EntryList 中等待,会选择其中一个线程唤醒,并将锁标志位设置为唤醒线程的 ID。
-
锁的升级:
- 初始时,Mark Word 的锁标志位为无锁状态(01)。
- 当一个线程获取锁时,Mark Word 的锁标志位变为偏向锁状态(00),记录获取偏向锁的线程 ID。
- 如果有其他线程尝试获取锁,会升级为轻量级锁状态,通过 CAS 操作进行加锁。
- 如果多个线程争用轻量级锁,会升级为重量级锁,即使用 Monitor。
总体而言,Synchronized
通过对对象头的 Mark Word 进行操作,以及通过 Monitor 进行锁的获取和释放,来实现对共享资源的同步控制。这种同步机制保证了对共享资源的互斥访问。需要注意的是,锁的升级过程旨在优化性能,避免过多的锁竞争。
13.volatile与synchronized的区别是什么?volatile作用(高频考点)
背景知识了解
- Java的线程抽象内存模型
Java的线程抽象内存模型中定义了每个线程都有一份自己的私有内存,里面存放自己私有的数据,其他线程不能直接访问,而一些共享数据则存在主内存中,供所有线程进行访问。
上图中,如果线程A和线程B要进行通信,就要经过主内存,比如线程B要获取线程A修改后的共享变量的值,要经过下面两步:
(1)、线程A修改自己的共享变量副本,并刷新到了主内存中。
(2)、线程B读取主内存中被A更新过的共享变量的值,同步到自己的共享变量副本中。
- Java多线程中的原子性、可见性、有序性
(1)、原子性:是指线程的多个操作是一个整体,不能被分割,要么就不执行,要么就全部执行完,中间不能被打断。
(2)、可见性:是指线程之间的可见性,就是一个线程修改后的结果,其他的线程能够立马知道。
(3)、有序性:为了提高执行效率,java中的编译器和处理器可以对指令进行重新排序,重新排序会影响多线程并发的正确性,有序性就是要保证不进行重新排序(保证线程操作的执行顺序)。
- volatile关键字的作用
volatile关键字的作用就是保证了可见性和有序性(不保证原子性),如果一个共享变量被volatile关键字修饰,那么如果一个线程修改了这个共享变量后,其他线程是立马可知的。
为什么是这样的呢?比如,线程A修改了自己的共享变量副本,这时如果该共享变量没有被volatile修饰,那么本次修改不一定会马上将修改结果刷新到主存中,如果此时B去主存中读取共享变量的值,那么这个值就是没有被A修改之前的值。如果该共享变量被volatile修饰了,那么本次修改结果会强制立刻刷新到主存中,如果此时B去主存中读取共享变量的值,那么这个值就是被A修改之后的值了。
volatile能禁止指令重新排序,在指令重排序优化时,在volatile变量之前的指令不能在volatile之后执行,在volatile之后的指令也不能在volatile之前执行,所以它保证了有序性。
- synchronized关键字的作用
synchronized提供了同步锁的概念,被synchronized修饰的代码段可以防止被多个线程同时执行,必须一个线程把synchronized修饰的代码段都执行完毕了,其他的线程才能开始执行这段代码。
因为synchronized保证了在同一时刻,只能有一个线程执行同步代码块,所以执行同步代码块的时候相当于是单线程操作了,那么线程的可见性、原子性、有序性(线程之间的执行顺序)它都能保证了。
volatile关键字和synchronized关键字的区别
(1)、volatile只能作用于变量,使用范围较小。synchronized可以用在变量、方法、类、同步代码块等,使用范围比较广。
(2)、volatile只能保证可见性和有序性,不能保证原子性。而可见性、有序性、原子性synchronized都可以保证。
(3)、volatile不会造成线程阻塞。synchronized可能会造成线程阻塞。
14.synchronized和Lock的区别(高频考点)
背景知识了解
- synchronized
Java语言的关键字,可用来给对象和方法或者代码块加锁,当它锁定一个方法或者一个代码块的时候,同一时刻最多只有一个线程执行这段代码。当两个并发线程访问同一个对象object中的这个加锁同步代码块时,一个时间内只能有一个线程得到执行。另一个线程必须等待当前线程执行完这个代码块以后才能执行该代码块。然而,当一个线程访问object的一个加锁代码块时,另一个线程仍然可以访问该object中的非加锁代码块。
- lock
(1)synchronized的缺陷
synchronized是java中的一个关键字,也就是说是Java语言内置的特性。那么为什么会出现Lock呢?
如果一个代码块被synchronized修饰了,当一个线程获取了对应的锁,并执行该代码块时,其他线程便只能一直等待,等待获取锁的线程释放锁,而这里获取锁的线程释放锁只会有两种情况:
1)获取锁的线程执行完了该代码块,然后线程释放对锁的占有;
2)线程执行发生异常,此时JVM会让线程自动释放锁。
那么如果这个获取锁的线程由于要等待IO或者其他原因(比如调用sleep方法)被阻塞了,但是又没有释放锁,其他线程便只能等待,试想一下,这多么影响程序执行效率。因此就需要有一种机制可以不让等待的线程一直无期限地等待下去(比如只等待一定的时间或者能够响应中断),通过Lock就可以办到。
再举个例子:当有多个线程读写文件时,读操作和写操作会发生冲突现象,写操作和写操作会发生冲突现象,但是读操作和读操作不会发生冲突现象。但是采用synchronized关键字来实现同步的话,就会导致一个问题:如果多个线程都只是进行读操作,所以当一个线程在进行读操作时,其他线程只能等待无法进行读操作。因此就需要一种机制来使得多个线程都只是进行读操作时,线程之间不会发生冲突,通过Lock就可以办到。
另外,通过Lock可以知道线程有没有成功获取到锁。这个是synchronized无法办到的。
总结一下,也就是说Lock提供了比synchronized更多的功能。但是要注意以下几点:
1)Lock不是Java语言内置的,synchronized是Java语言的关键字,因此是内置特性。Lock是一个类,通过这个类可以实现同步访问;
2)Lock和synchronized有一点非常大的不同,采用synchronized不需要用户去手动释放锁,当synchronized方法或者synchronized代码块执行完之后,系统会自动让线程释放对锁的占用;而Lock则必须要用户去手动释放锁,如果没有主动释放锁,就有可能导致出现死锁现象。
(2)java.util.concurrent.locks包下常用的类
java
public interface Lock {
/*获取锁,如果锁被其他线程获取,则进行等待*/
void lock();
/**当通过这个方法去获取锁时,如果线程正在等待获取锁,则这个线程能够响应中断,
即中断线程的等待状态。也就使说,当两个线程同时通过lock.lockInterruptibly()想获取某个锁时,
假若此时线程A获取到了锁,而线程B只有在等待,那么对线程B调用threadB.interrupt()方法能够中断线程B的等待过程。*/
void lockInterruptibly() throws InterruptedException;
/**tryLock()方法是有返回值的,它表示用来尝试获取锁,如果获取成
*功,则返回true,如果获取失败(即锁已被其他线程获取),则返回
*false,也就说这个方法无论如何都会立即返回。在拿不到锁时不会一直在那等待。*/
boolean tryLock();
/*tryLock(long time, TimeUnit unit)方法和tryLock()方法是类似的,
只不过区别在于这个方法在拿不到锁时会等待一定的时间,在时间期限之内如果还拿不到锁,就返回false。
如果如果一开始拿到锁或者在等待期间内拿到了锁,则返回true。*/
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
void unlock(); //释放锁
Condition newCondition();
}
注意:
当一个线程获取了锁之后,是不会被interrupt()方法中断的。因为单独调用interrupt()方法不能中断正在运行过程中的线程,只能中断阻塞过程中的线程。而用synchronized修饰的话,当一个线程处于等待某个锁的状态,是无法被中断的,只有一直等待下去。
(3)ReentrantLock
ReentrantLock,意思是"可重入锁",是唯一实现了Lock接口的类,并且ReentrantLock提供了更多的方法。
如果锁具备可重入性,则称作为可重入锁。像synchronized和ReentrantLock都是可重入锁,可重入性在我看来实际上表明了锁的分配机制:基于线程的分配,而不是基于方法调用的分配。
举个简单的例子,当一个线程执行到某个synchronized方法时,比如说method1,而在method1中会调用另外一个synchronized方法method2,此时线程不必重新去申请锁,而是可以直接执行方法method2。
java
class MyClass {
public synchronized void method1() {
method2();
}
public synchronized void method2() {
}
}
synchronized和lock区别
1)Lock是一个接口,而synchronized是Java中的关键字,synchronized是内置的语言实现;
2)synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;
而Lock在发生异常时,如果没有主动通过unLock()去释放锁,则很可能造成死锁现象,因此使用Lock时需要在finally块中释放锁;
3)Lock可以让等待锁的线程响应中断,而synchronized却不行,使用synchronized时,等待的线程会一直等待下去,不能够响应中断;
4)通过Lock可以知道有没有成功获取锁,而synchronized却无法办到。
5)Lock可以提高多个线程进行读操作的效率。
在性能上来说,如果竞争资源不激烈,两者的性能是差不多的,而当竞争资源非常激烈时(即有大量线程同时竞争),此时Lock的性能要远远优于synchronized。所以说,在具体使用时要根据适当情况选择。
15.Atomic类如何保证原子性(CAS操作)(高频考点)
前提知识:Atomic 内部的value 使用volatile保证内存可见性,使用CAS保证原子性
- volatile保证内存可见性:
打开AtomicInteger的源码可以看到:
java
private static final Unsafe unsafe = Unsafe.getUnsafe();
private volatile int value;
volatile关键字用来保证内存的可见性(但不能保证线程安全性),线程读的时候直接去主内存读,写操作完成的时候立即把数据刷新到主内存当中。
- 使用CAS保证原子性:
java
/**
* Atomically sets the value to the given updated value
* if the current value {@code ==} the expected value.
*
* @param expect the expected value
* @param update the new value
* @return {@code true} if successful. False return indicates that
* the actual value was not equal to the expected value.
*/
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
从注释就可以看出:当线程写数据的时候,先对内存中要操作的数据保留一份旧值,真正写的时候,比较当前的值是否和旧值相同,如果相同,则进行写操作。如果不同,说明在此期间值已经被修改过,则重新尝试。
compareAndSet使用Unsafe调用native本地方法CAS(CompareAndSet)递增数值。CAS利用CPU调用底层指令实现。
**16.**AtomicInteger、AtomicBoolean这些类之所以在高并发时高效,共同的原因是?
AtomicInteger
、AtomicBoolean
等原子类之所以在高并发时高效,共同的原因是因为它们使用了硬件级别的原子操作来实现对变量的更新,避免了使用锁带来的性能开销和线程阻塞。
具体来说,它们的高效性可以归结为以下几个方面:
-
无锁机制:原子类通过底层的CAS(Compare and Swap)操作实现对变量的原子更新,而不需要使用显式的锁。CAS操作是一种硬件级别的原子操作,可以保证在多线程环境下对变量的安全访问和更新,避免了使用锁带来的性能开销和线程阻塞。
-
并发性能 :原子类的实现通常基于CPU的原子指令(比如
compareAndSet()
方法),在多核处理器上能够充分利用硬件并发性能,实现高效的并发访问。 -
无阻塞:由于原子类采用了无锁的方式实现对变量的更新,因此不存在线程阻塞的问题。即使有大量线程同时访问原子类中的变量,也不会导致线程的长时间阻塞等待锁资源释放,从而提高了系统的响应性和吞吐量。
综上所述,AtomicInteger
、AtomicBoolean
等原子类通过利用硬件级别的原子操作来实现对变量的原子更新,从而实现了高效的并发访问和线程安全性,成为了在高并发场景下常用的并发工具之一。
17.关于 Atomic 类中的主要变量如下,其使用了 volatile 关键字进行修饰。你知道它在源码中的主要意义是?(高频考点)
使用了 volatile 关键字的变量,每当变量的值有变动的时候,都会将更改立即同步到主内存中;而如果某个线程想要使用这个变量,就先要从主存中刷新到工作内存,这样就确保了变量的可见性。有了这个关键字的修饰,就能保证每次比较的时候,拿到的值总是最新的。
18.CAS?CAS 有什么缺陷,如何解决?(高频考点)
更详细的见文章:CAS技术分析 + 超越并发瓶颈:CAS与乐观锁的智慧应用
CAS(Compare and Swap)是一种并发编程中的原子操作,用于实现多线程环境下的无锁同步。它基于比较当前值与期望值的方式来更新变量的值,只有在当前值与期望值相等的情况下才进行更新,否则不进行更新。
CAS的主要缺陷是ABA问题。ABA问题指的是,在执行CAS操作期间,变量的值经过一系列的修改先变成了A,然后又被修改为B,最后又被修改回A。在这种情况下,CAS操作会错误地认为变量的值没有被其他线程修改过,导致操作成功,但实际上变量的值已经发生了变化。
为了解决ABA问题,可以采取以下两种方法:
- 版本号或标记:在变量值的基础上增加一个版本号或标记,每次修改时都更新版本号或标记。这样,即使变量的值从A变为B再变回A,由于版本号或标记的变化,CAS操作会正确地判断变量是否被修改过。
- 带有回退的CAS:在执行CAS操作时,除了比较当前值与期望值外,还比较变量的修改历史。如果发现变量的值在修改期间发生了变化,即使当前值与期望值相等,CAS操作也会失败,需要重新尝试。
Java中的Atomic类提供了基于CAS操作的原子类,如AtomicInteger、AtomicLong等。这些原子类已经内部处理了ABA问题,使用了类似版本号或标记的机制来解决ABA问题,从而提供了线程安全的原子操作。
需要注意的是,尽管CAS是一种无锁的同步机制,但在高并发场景下,由于CAS操作可能会多次失败和重试,从而导致性能下降。因此,在选择使用CAS时,需要根据具体场景综合考虑其性能和实现复杂度。
19.比较和替换是两个动作,CAS 是如何保证这两个操作的原子性呢?
具体理解可见:超越并发瓶颈:CAS与乐观锁的智慧应用
直接以AtomicInteger
中 CAS 操作的原子性保证来进行理解。
Java 层次
AtomicInteger
类中的 compareAndSet
方法用于执行 CAS 操作,其代码如下:
public final boolean compareAndSet(int expectedValue, int newValue) {
return U.compareAndSetInt(this, VALUE, expectedValue, newValue);
}
这里的 U
是 Unsafe
类的实例,VALUE
是内存偏移量。compareAndSetInt
是 Unsafe
类中的一个本地方法,直接调用底层的硬件指令来实现原子操作。
public final native boolean compareAndSetInt(Object o, long offset, int expected, int x);
JVM 层次
Unsafe
类中 compareAndSetInt
方法的实现会调用 weakCompareAndSetInt
方法,该方法通过自旋重试实现CAS操作:
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
v = getIntVolatile(o, offset);
} while (!weakCompareAndSetInt(o, offset, v, v + delta));
return v;
}
在这个方法中,getIntVolatile
获取当前值,weakCompareAndSetInt
尝试更新值,如果更新失败,则重复上述过程,直到成功,即自旋重试。
硬件层次
在 Linux 系统的 x86 架构上,CAS 操作最终会映射到 cmpxchgl
汇编指令,这是由 os_cpu/linux_x86/atomic_linux_x86.hpp
文件中的代码实现的:
template<>
template<typename T>
inline T Atomic::PlatformCmpxchg<4>::operator()(T exchange_value,
T volatile* dest,
T compare_value,
atomic_memory_order /* order */) const {
STATIC_ASSERT(4 == sizeof(T));
__asm__ volatile ("lock cmpxchgl %1,(%3)"
: "=a" (exchange_value)
: "r" (exchange_value), "a" (compare_value), "r" (dest)
: "cc", "memory");
return exchange_value;
}
这里的 cmpxchgl
指令是关键。这条汇编指令的作用是:
- 比较寄存器
EAX
中的值(compare_value
)和内存地址dest
中的值。 - 如果两者相等,则将
exchange_value
存储到dest
中。 - 如果不相等,则将
dest
中的值加载到EAX
中。
lock
前缀确保了操作的原子性,这意味着在多处理器系统中,该指令在执行时会锁住总线或使用缓存一致性协议,保证其他处理器不能访问内存地址,直到操作完成。
在不同的硬件平台上,支持CAS操作的指令可能不同,但其基本原理是一致的:
- x86 平台 :x86处理器提供了
CMPXCHG
指令来实现CAS操作。这个指令是原子的,即在执行过程中,不会被其他指令中断。- PowerPC 平台 :PowerPC处理器提供了
lwarx
和stwcx.
指令组合来实现CAS操作,这些指令也确保了操作的原子性。- ARM 平台 :ARM处理器提供了
LDREX
和STREX
指令组合来实现CAS操作。
总结
硬件指令 cmpxchgl
结合 lock
前缀保证了在多处理器环境下的原子性,即整个比较和替换操作是不可分割的,这就是 CAS 操作能够实现原子性的原因。
20.Java不可重入锁与可重入锁的区别如何理解?
更详细的见文章:可重入锁 VS 非可重入锁
可重入锁(Reentrant Lock)和不可重入锁(Non-reentrant Lock)是锁的两种不同实现方式,其主要区别在于是否支持同一个线程多次获取同一把锁。
**可重入锁允许同一个线程多次获取同一把锁,而不可重入锁不允许同一个线程多次获取同一把锁。**具体来说,可重入锁会维护一个获取锁的计数器,每次成功获取锁时,计数器会加1;线程释放锁时,计数器会减1。只有当计数器归零时,其他线程才能获取该锁。这样,同一个线程在持有锁的情况下,可以再次获取同一把锁而不会被阻塞,称为锁的重入性。
**不可重入锁则不支持同一个线程多次获取同一把锁。**当一个线程已经持有该锁时,再次尝试获取同一把锁会导致线程被阻塞,直到其他线程释放该锁。
理解可重入锁和不可重入锁的区别有助于避免死锁和实现复杂的同步逻辑。可重入锁能够适应更复杂的同步需求,允许在同一线程中递归地调用同步方法或代码块,而不可重入锁则需要谨慎使用,以防止死锁和逻辑错误。
在Java中,synchronized关键字实现的锁是可重入锁,即同一个线程在持有锁的情况下可以再次获取同一把锁。而ReentrantLock类也是可重入锁的实现,它提供了更多灵活性和扩展性,可以用于更复杂的同步场景。
21.无锁 VS 偏向锁 VS 轻量级锁 VS 重量级锁,解释锁升级?(高频考点)
在Java中,锁的升级是指在多线程竞争的情况下,从低级别的锁逐渐升级到高级别的锁。Java的锁升级过程包括无锁、偏向锁、轻量级锁和重量级锁,每个级别的锁都有不同的开销和适用场景。
- 无锁:在无竞争的情况下,线程可以自由地访问共享数据,无需任何锁机制。
- 偏向锁(Biased Locking):当只有一个线程访问共享数据时,使用偏向锁可以减少同步的开销。偏向锁会偏向于第一个获取锁的线程,将对象头标记为偏向锁,并将线程ID记录在对象头中。此后,该线程再次访问同步块时,无需竞争,直接获取锁。偏向锁的目标是提供低延迟的锁操作。
- 轻量级锁(Lightweight Locking):当多个线程同时访问同一块同步代码时,偏向锁会升级为轻量级锁。轻量级锁使用CAS(Compare and Swap)操作来尝试获取锁,如果成功获取锁,则继续执行同步代码块。如果获取锁失败,则表示存在竞争,升级为重量级锁。
- 重量级锁(Heavyweight Locking):当多个线程竞争同步锁时,轻量级锁会升级为重量级锁。**重量级锁使用操作系统的互斥量(Mutex)来实现,确保同一时间只有一个线程可以访问同步代码块。**当线程无法获取重量级锁时,会被阻塞挂起,直到锁被释放。
锁的升级过程是动态的,根据竞争情况和线程访问模式来进行判断和转换。如果竞争激烈,锁会很快升级为重量级锁;如果竞争较小或仅有一个线程访问,锁可能一直保持为偏向锁。锁升级的过程会带来一定的开销,因此,在设计多线程应用程序时,需要综合考虑锁的升级过程和并发性能的平衡。需要注意的是,锁的升级是由Java虚拟机自动进行的,开发人员无需显式控制。锁升级机制的目标是提供更好的并发性能和适应不同的多线程竞争场景。
更详细的见文章:无锁 VS 偏向锁 VS 轻量级锁 VS 重量级锁
22.乐观锁 VS 悲观锁?公平锁 VS 非公平锁?独享锁 VS 共享锁?
更详细的见文章:乐观锁 VS 悲观锁?公平锁 VS 非公平锁?独享锁 VS 共享锁?
23.自旋锁 VS 适应性自旋锁,简单介绍
更详细的见文章:Java中常用的锁总结与理解
24.为什么读多写少的情况,就适合使用乐观锁呢?悲观锁在读多写少的情况下,不也是有很少的冲突吗?(高频考点)
主要内容和介绍具体可见:超越并发瓶颈:CAS与乐观锁的智慧应用
乐观锁适用于读多写少的情况的原因
- 乐观锁不阻塞读操作:乐观锁在读取数据时并不会进行加锁操作,而是先读取数据,然后在更新数据时检查是否被其他线程修改过。因此,即使有写操作正在进行,读操作也不会被阻塞,从而可以实现读操作的并发执行。
- 乐观锁的冲突少:在读多写少的情况下,写操作的频率较低,因此冲突的概率也相对较低。乐观锁的重试操作是在发生冲突时进行的,因此在冲突较少的情况下,重试的概率也较低,从而可以更高效地处理并发冲突。
- 乐观锁的开销低:乐观锁的实现通常比较轻量级,不需要频繁地进行加锁和解锁操作,因此在读多写少的情况下,乐观锁的性能通常会更好。
悲观锁在读多写少的情况下也有冲突少的特点,为什么不适合呢?
尽管悲观锁在读多写少的情况下可能会有较少的冲突,但它的主要问题在于加锁这个动作上:
- 悲观锁在读取数据时通常会对共享资源进行加锁,这会导致其他线程无法同时进行读操作。即使读操作之间并没有冲突,也会由于加锁操作而导致不必要的阻塞。
- 悲观锁在进行加锁和解锁操作时会引入额外的开销,尤其是在并发量较大的情况下,频繁的加锁和解锁操作会降低系统的性能。
尽管悲观锁在一些情况下也能够处理并发问题,但在读多写少的情况下,乐观锁更适合,因为它更符合读多写少的特点,可以更好地实现读操作的并发执行,提高系统的性能。
25.死锁发生的原因
死锁是指在并发系统中,两个或多个进程(或线程)因为争夺资源而被永久地阻塞,导致系统无法继续执行的状态。以下是导致死锁发生的常见原因:
- 互斥条件(Mutual Exclusion):某些资源一次只能被一个进程(或线程)使用,如果一个进程占用了资源,其他进程必须等待。
- 请求和保持条件(Hold and Wait):进程占有了至少一个资源,并且在等待其他进程的资源时保持对已占有资源的占用。
- 不可剥夺条件(No Preemption):资源只能由持有者显式地释放,其他进程无法抢占已被占用的资源。
- 循环等待条件(Circular Wait):多个进程形成一种循环等待资源的关系,每个进程都在等待下一个进程所占有的资源。
当这四个条件同时满足时,就可能发生死锁。当系统进入死锁状态后,没有外部干预,系统将无法恢复正常。
为了避免死锁的发生,可以采取以下策略:
- 破坏互斥条件:对于某些资源,允许多个进程共享或同时访问。
- 破坏请求和保持条件:进程请求资源时不保持已占有的资源,而是先释放已占有的资源再重新请求。
- 破坏不可剥夺条件:对于某些资源,允许系统剥夺已占有的资源,将其分配给其他进程。
- 破坏循环等待条件:通过定义资源的线性顺序,要求进程按顺序申请资源,避免形成循环等待的情况。
死锁是一种复杂的并发问题,需要细心的设计和合理的资源管理来避免。在实际开发中,可以使用死锁检测、死锁避免、死锁恢复等技术手段来处理死锁问题。
26.用java 代码实现一个死锁用例,说说怎么解决死锁问题?回到用例代码下,如何解决死锁问题呢?(高频考点)
死锁是一个并发编程中常见的问题,它发生在两个或更多线程互相持有对方所需要的资源而无法继续执行的情况。下面是用 Java 代码实现一个简单的死锁示例:
java
package org.zyf.javabasic.test.thread;
/**
* @program: zyfboot-javabasic
* @description: 死锁用例
* @author: zhangyanfeng
* @create: 2023-08-13 22:37
**/
public class DeadlockExample {
private static Object resource1 = new Object();
private static Object resource2 = new Object();
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
synchronized (resource1) {
System.out.println("Thread 1: Holding resource 1...");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Thread 1: Waiting for resource 2...");
synchronized (resource2) {
System.out.println("Thread 1: Acquired resource 2!");
}
}
});
Thread thread2 = new Thread(() -> {
synchronized (resource2) {
System.out.println("Thread 2: Holding resource 2...");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Thread 2: Waiting for resource 1...");
synchronized (resource1) {
System.out.println("Thread 2: Acquired resource 1!");
}
}
});
thread1.start();
thread2.start();
}
}
在这个示例中,两个线程(thread1 和 thread2)分别持有 resource1 和 resource2,并试图获取对方的资源。由于每个线程都在等待另一个线程释放资源,因此这段代码会导致死锁。
解决死锁问题需要采取一些常见的方法和策略,以确保线程在并发执行时不会发生死锁。以下是一些解决死锁问题的方法:
- 避免使用多个锁:尽量减少在代码中使用多个锁,这样可以减少死锁的可能性。如果有多个锁,确保线程按照相同的顺序获取锁,这样可以避免循环等待导致的死锁。
- 使用超时机制:在尝试获取锁时,设置一个超时时间,如果在超时时间内无法获取到锁,则放弃该操作,释放已经持有的锁,并进行回退操作,避免死锁发生。
- 使用Lock对象:Java提供了`java.util.concurrent.locks.Lock`接口,它比传统的synchronized块更加灵活,可以使用`tryLock()`方法尝试获取锁,并在获取失败时进行后续处理,从而避免死锁。
- 按顺序获取锁:在使用多个锁的情况下,确保线程按照固定的顺序获取锁,这样可以避免循环等待。
- 死锁检测:有些系统和工具可以进行死锁检测,监测程序运行时的锁和资源使用情况,如果发现潜在的死锁情况,可以采取相应的措施,例如中断某个线程,解除死锁。
- 避免长时间持有锁:在设计并发程序时,尽量避免长时间持有锁,尽快完成对资源的访问和操作,然后释放锁,从而减少死锁的可能性。
- 合理的资源分配策略:设计合理的资源分配策略,避免出现资源竞争的情况,从而减少死锁的发生。
请注意,死锁问题可能比较复杂,解决方法需要根据具体的代码和场景来确定。在设计并发程序时,要注意多线程之间的资源竞争和互斥关系,合理地选择锁和同步方式,并进行充分的测试和验证,以确保程序在运行时不会出现死锁问题。
在上面提供的死锁代码示例中,可以通过改变锁的获取顺序来解决死锁问题。确保线程在获取锁时按照相同的顺序来避免循环等待。具体来说,可以修改线程2的代码,将它的锁获取顺序与线程1相同,从而避免死锁。
下面是修改后的代码示例:
java
package org.zyf.javabasic.test.thread;
/**
* @program: zyfboot-javabasic
* @description: 死锁用例解决
* @author: zhangyanfeng
* @create: 2023-08-13 22:41
**/
public class DeadlockDealExample {
private static Object resource1 = new Object();
private static Object resource2 = new Object();
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
synchronized (resource1) {
System.out.println("Thread 1: Holding resource 1...");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Thread 1: Waiting for resource 2...");
synchronized (resource2) {
System.out.println("Thread 1: Acquired resource 2!");
}
}
});
Thread thread2 = new Thread(() -> {
synchronized (resource1) {
System.out.println("Thread 2: Holding resource 1...");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Thread 2: Waiting for resource 2...");
synchronized (resource2) {
System.out.println("Thread 2: Acquired resource 2!");
}
}
});
thread1.start();
thread2.start();
}
}
通过将线程2的锁获取顺序调整为先获取resource1,再获取resource2,就能够避免死锁。当线程1持有resource1时,线程2无法获取resource1,从而避免了相互等待对方资源的情况,解决了死锁问题。
27.请实现让10个任务同时并发启动?
使用 ExecutorService
实现让10个任务同时并发启动:
java
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ConcurrentTasks {
public static void main(String[] args) {
int numberOfTasks = 10;
ExecutorService executor = Executors.newFixedThreadPool(numberOfTasks);
for (int i = 0; i < numberOfTasks; i++) {
Runnable task = new Task("Task " + (i + 1));
executor.execute(task);
}
executor.shutdown();
}
static class Task implements Runnable {
private final String name;
public Task(String name) {
this.name = name;
}
@Override
public void run() {
System.out.println("Executing " + name);
// 在这里放置任务的逻辑
}
}
}
创建了一个固定大小的线程池,大小为10,然后循环创建10个任务,并使用 executor.execute(task)
方法将任务提交到线程池中执行。由于线程池的大小为10,因此这10个任务可以同时并发启动执行。
28.AQS理论的数据结构是什么样的?(高频考点)
更详细的见文章:从ReentrantLock理解AQS的原理及应用总结
AQS全称为AbstractQueuedSynchronizer,是Java中用于构建锁和同步器的框架性组件,它是Java并发包中ReentrantLock、Semaphore、ReentrantReadWriteLock等同步器的基础。AQS的设计思想是,在其内部维护了一个双向队列,用于管理请求锁的线程。当有线程请求锁时,AQS会将其封装成一个Node节点,并加入到等待队列中,线程则会进入阻塞状态。当持有锁的线程释放锁时,AQS会从等待队列中唤醒一个线程来获取锁,从而实现线程的同步和互斥。
AQS的主要特点包括:
- 支持独占模式和共享模式。独占模式下只允许一个线程持有锁,共享模式下可以允许多个线程同时持有锁。
- 内部维护了一个双向队列,用于管理请求锁的线程,队列中的节点是线程的封装。
- 通过CAS(Compare And Swap)操作实现状态的改变,状态可以是任意int类型的变量。
- 具有可重入性,即同一个线程可以多次获取同一把锁而不会出现死锁。
AQS的实现被广泛应用于Java并发包中的各种同步器,如ReentrantLock、ReentrantReadWriteLock、Semaphore、CountDownLatch等。AQS为这些同步器提供了一个统一的基础框架,并且可以让开发人员基于此进行扩展和定制化。
AQS内部有3个对象,一个是state(用于计数器,类似gc的回收计数器),一个是线程标记(当前线程是谁加锁的),一个是阻塞队列。
它内部实现主要是状态变量state和一个FIFO队列来完成,同步队列的头结点是当前获取到同步状态的结点,获取同步状态state失败的线程,会被构造成一个结点(或共享式或独占式 )加入到同步队列尾部(采用自旋CAS来保证此操作的线程安全),随后线程会阻塞;释放时唤醒头结点的后继结点,使其加入对同步状态的争夺中。
29.ReentrantLock底层公平锁和非公平锁的原理(高频考点)
底层公平锁和非公平锁的原理涉及到 ReentrantLock
内部的同步器 Sync
的实现。底层原理:
非公平锁(默认)
在非公平锁模式下,ReentrantLock
使用的同步器是 NonfairSync
类。NonfairSync
内部使用了 CAS 操作,通过 compareAndSetState
方法来修改锁的状态,尝试直接获取锁。如果获取失败,线程会进入等待队列并尝试重新获取锁。
公平锁
在公平锁模式下,ReentrantLock
使用的同步器是 FairSync
类。FairSync
实现了一种公平的获取锁的机制。当线程尝试获取锁时,如果发现队列中已经有等待的线程,会将当前线程加入到等待队列中,然后进入自旋等待直到获得锁。
同步器的基础
无论是公平锁还是非公平锁,它们的底层同步器都基于 AbstractQueuedSynchronizer
(AQS)实现。AQS 提供了一个框架,用于构建基于队列的同步器。ReentrantLock
则在 AQS 的基础上实现了重入锁的语义。理解 AbstractQueuedSynchronizer
(AQS)对于理解 ReentrantLock
的底层原理是至关重要的。AQS 是一个用于构建锁和其他同步器的框架,它提供了一种基于 FIFO 等待队列的机制,用于管理线程的获取和释放资源。
-
状态管理: AQS 使用一个
int
类型的变量来表示同步状态。这个状态可以被不同的同步器进行修改和检查。比如,ReentrantLock
中的状态表示锁的持有次数。 -
等待队列: AQS 使用一个等待队列(
CLH
队列)来维护等待线程。这个队列是一个虚拟的双向链表,每个节点代表一个等待线程,按照 FIFO 的顺序进行排队。 -
原子性操作: AQS 提供了一些原子性的操作,比如
getState
、setState
、compareAndSetState
等,这些操作基于Unsafe
类的 CAS 操作。 -
独占锁与共享锁: AQS 支持独占锁和共享锁两种模式。
ReentrantLock
就是一个独占锁的典型例子,而CountDownLatch
可以用作共享锁的例子。 -
模板方法: AQS 是一个框架,它定义了一些模板方法,其中最为重要的是
tryAcquire
和tryRelease
。这两个方法需要被子类重写以实现具体的同步逻辑。在ReentrantLock
中,这两个方法分别对应着获取锁和释放锁的逻辑。
30.多线程中sleep与wait的区别是什么?
使用 sleep
主要是为了线程休眠,不考虑锁的释放和唤醒的问题。
使用 wait
主要是为了线程等待,并通常与锁和条件结合使用,需要在同步块或同步方法中调用。
sleep
和 wait
是多线程编程中用于线程等待的两种不同机制,它们的主要区别在于使用的上下文、作用对象以及条件触发等方面。
调用的上下文:
-
sleep
:sleep
是Thread
类的静态方法,直接通过线程对象调用。它不会释放持有的锁,即使当前线程持有某个对象的锁,调用sleep
后也不会释放该锁。 -
wait
:wait
是Object
类的实例方法,需要在对象的同步块或同步方法中调用。调用wait
会释放对象的锁,并使当前线程进入等待状态,直到其他线程调用相同对象的notify
或notifyAll
方法唤醒它。
作用对象:
-
sleep
:sleep
是线程级别的,它不依赖于任何对象,直接通过线程对象调用。 -
wait
:wait
是对象级别的,它必须在同步块或同步方法中调用,作用于当前对象。线程会等待其他线程调用相同对象的notify
或notifyAll
方法来唤醒它。
条件触发:
-
sleep
:sleep
会在指定的时间内阻塞当前线程,不依赖于外部条件的变化。即使指定的时间到达,也不会被其他线程主动唤醒。 -
wait
:wait
会阻塞当前线程,并且需要等待其他线程通过相同对象的notify
或notifyAll
方法来唤醒。通常,wait
会与某个条件结合使用,即在等待之前检查某个条件,等待满足条件时才继续执行。
错误使用的情况:
-
sleep
: 如果在同步块或同步方法中使用sleep
,它不会释放锁,可能会导致其他线程无法进入同步块。 -
wait
: 如果在没有持有锁的情况下调用wait
,会抛出IllegalMonitorStateException
异常。
31.notify和notifyAll区别?
在 Java 中,notify()
和 notifyAll()
都是用于线程间通信的方法,用于唤醒等待在对象监视器上的线程。它们之间的主要区别在于:
notify()
方法:
notify()
方法用于唤醒在当前对象的监视器上等待的单个线程。如果有多个线程等待在同一个对象的监视器上,那么只会唤醒其中一个线程,但是具体唤醒哪个线程是不确定的,取决于 JVM 的实现。- 唤醒的线程可以通过竞争重新获取对象的锁,并继续执行。
notifyAll()
方法:
notifyAll()
方法用于唤醒在当前对象的监视器上等待的所有线程。如果有多个线程等待在同一个对象的监视器上,那么所有等待的线程都会被唤醒。- 唤醒的线程可以通过竞争重新获取对象的锁,并继续执行。
因此,notify()
方法只唤醒一个线程,而 notifyAll()
方法会唤醒所有等待的线程。通常情况下,当多个线程等待同一个条件变量时,应该使用 notifyAll()
方法来确保所有等待的线程都被唤醒,以避免发生死锁或者部分线程被遗漏的情况。
32.除了用Object.wait和Object.notifyAll来实现线程间的交互外,你还会常用哪些来实现?
除了使用 Object.wait()
和 Object.notifyAll()
方法来实现线程间的交互外,还可以使用以下几种方式:
-
使用Lock和Condition :通过
java.util.concurrent.locks.Lock
接口和java.util.concurrent.locks.Condition
接口提供的方法来实现线程间的协调和通信。使用Condition
的await()
和signalAll()
方法可以代替Object.wait()
和Object.notifyAll()
方法。 -
使用CountDownLatch :
java.util.concurrent.CountDownLatch
是一种同步工具类,它可以使一个或多个线程等待其他线程完成操作后再执行。通过调用CountDownLatch
的await()
和countDown()
方法可以实现线程间的等待和触发。 -
使用CyclicBarrier :
java.util.concurrent.CyclicBarrier
也是一种同步工具类,它可以使一组线程相互等待,直到所有线程都到达某个屏障点后再继续执行。通过调用CyclicBarrier
的await()
方法可以实现线程间的等待和同步。 -
使用Semaphore :
java.util.concurrent.Semaphore
是一种计数信号量,它可以限制同时访问某个资源的线程数量。通过调用Semaphore
的acquire()
和release()
方法可以实现线程的互斥和同步。 -
使用BlockingQueue :
java.util.concurrent.BlockingQueue
是一种线程安全的队列,它提供了阻塞式的读写操作。通过将BlockingQueue
作为线程间的共享数据结构,可以实现线程间的安全通信。
33.ThreadLocal的原理和实现(高频考点)
了解ThreadLocal
- ThreadLocal主要用来存储当前线程上下文的变量信息,它可以保障存储进去的数据,只能被当前线程读取到,并且线程之间不会相互影响。
- ThreadLocal提供了set和get函数,set函数表示把数据存储到线程的上下文中,get函数表示从线程的上下文中读取数据。通过get函数读取数据,类似于以当前线程线程为key从map中读取数据。
- 在实际的应用场景中,InheritableThreadLocal可能更常用,它不仅可以取出当前线程存储的数据,还可以在子线程中读取父线程存储的数据。某些业务场景中,需要开启子线程,InheritableThreadLocal就派上用场了。
典型的应用场景
- 数据库事务:事务的实现原理非常简单,只需要在整个请求的处理过程中,用同一个connection开启事务、执行sql、提交事务就可以了。按照这个思路,实现起来也有两种方案:一种就是在第一次执行的时候 ,获取connection,在调用其他函数的时候,显示的传递connection对象。这种方案,只能存在于学习的demo中,无法应用到项目实践。另一种方案就是通过AOP的方式,对执行数据库事务的函数进行拦截。函数开始前,获取connection开启事务并存储在ThreadLocal中,任何用到connection的地方,从ThreadLocal中获取,函数执行完毕后,提交事务释放connection。
- web项目中的用户登录信息:web项目中,用户的登录信息通常保存在session中。按照分层的设计理念,往往会被分成controller层、service层、dao层等等,还约定在service层是不能处理request、session等对象的。一种方案是调用service函数的时候,显示的传递用户信息;另一种方案则是用到了ThreadLocal,做一个拦截器,把用户信息放在ThreadLocal中,在任何用到用户信息的时候,只需要从TreadLocal中读取就可以了。
ThreadLocal实现原理
- step1:首先看一下ThreadLocalMap,它是在ThreadLocal定义的一个内部类,看名字,就可以知道它用你来存储键值对的。只不过呢,它的Key只能是ThreadLocal对象。
- step2:再来看一下Thread,它有个ThreadLocalMap类型的属性threadLocals。
- step3:最后看一下get()函数的实现,得到当前线程的ThreadLocalMap,然后以当前的ThreadLocal对象为key,读取数据。这也就解释了为什么线程之间不会相互干扰,因为读取数据的时候,是从当前线程的ThreadLocalMap中读取的。
34.ThreadLocal为什么要使用弱引用和内存泄露问题(高频考点)
ThreadLocal
使用弱引用的主要目的是为了防止内存泄漏。在多线程环境中,如果没有正确处理 ThreadLocal
的引用关系,可能导致线程结束后,ThreadLocal
对象及其对应的值无法被垃圾回收,从而造成内存泄漏。
内存泄漏场景
强引用时的问题: 如果 ThreadLocal
使用强引用,当一个线程持有 ThreadLocal
对象,并且该线程长时间存活,那么 ThreadLocal
对象及其对应的值将一直存在于内存中。
即使该线程结束,ThreadLocal
对象对应的值在 ThreadLocalMap
中仍然存在,因为 ThreadLocalMap
是线程的一个字段,会一直存在于内存中。
线程结束时的清理问题: 如果一个线程结束,但没有显式调用 ThreadLocal
的 remove
方法来清理对应的值,那么这部分内存将一直被占用。
在长时间运行的服务或应用中,可能会创建大量的 ThreadLocal
实例,如果不及时清理,可能会导致大量内存泄漏。
弱引用的解决方案
使用弱引用可以解决上述内存泄漏问题:
**弱引用特点:**弱引用在垃圾回收时会被更容易地回收。如果一个对象只被弱引用引用,而没有被强引用引用,那么在下一次垃圾回收时,这个对象就会被回收。
ThreadLocalMap.Entry 使用弱引用: ThreadLocalMap.Entry
是 ThreadLocalMap
中的元素,其中的 ThreadLocal
使用弱引用。当 ThreadLocal
对象被垃圾回收时,对应的 Entry
也会被回收。
解决内存泄漏: 当线程结束时,ThreadLocalMap
会被回收,其中的弱引用 ThreadLocal
对象也会被回收,从而避免了内存泄漏。
总体而言,使用弱引用可以帮助 ThreadLocal
更及时地释放其引用的对象,从而避免因长时间保持引用而导致的内存泄漏问题。
35.ThreadLocal怎么解决内存泄露的问题?(高频考点)
ThreadLocal
可能导致内存泄漏的情况通常是由于没有及时清理 ThreadLocal
引用导致的。以下是一些帮助避免 ThreadLocal
导致的内存泄漏问题:
1.显式调用 remove
方法: 在不再需要使用 ThreadLocal
存储的数据时,建议显式调用 remove
方法,将 ThreadLocal
与其对应的值从当前线程的 ThreadLocalMap
中移除。
2.使用 try-with-resources(Java 7+): 在 Java 7 及更高版本中,可以使用 try-with-resources 语句来自动管理资源,包括 ThreadLocal
的清理。
3.使用弱引用: ThreadLocal
自身在实现上使用了弱引用,但如果存储的值是强引用,仍然可能导致内存泄漏。尽量存储使用弱引用引用的对象,或者确保在不需要时及时清理引用。
4.使用静态内部类: 如果需要使用 ThreadLocal
在静态范围内存储值,可以考虑使用静态内部类,并将 ThreadLocal
定义为该内部类的静态成员。这样可以避免直接持有外部类的引用,降低内存泄漏的风险。
5.使用框架: 某些框架和库提供了专门用于解决 ThreadLocal
内存泄漏问题的解决方案,例如 ThreadLocalCleaner 等。
注意:每次线程结束时,ThreadLocalMap
应该会被自动清理,但在某些情况下(例如线程池),线程可能不会立即终止,因此需要额外的注意来防止内存泄漏。
36.为什么要使用线程池?
更详细的见文章:对Java线程池ThreadPoolExecutor的理解分析
Java的线程池是运用场景最多的并发框架,几乎所有需要异步或者并发执行任务的程序都可以使用线程池。
合理使用线程池能带来的好处:
- 降低资源消耗。 通过重复利用已经创建的线程降低线程创建的和销毁造成的消耗。例如,工作线程Woker会无线循环获取阻塞队列中的任务来执行。
- 提高响应速度。 当任务到达时,任务可以不需要等到线程创建就能立即执行。
- 提高线程的可管理性。 线程是稀缺资源,Java的线程池可以对线程资源进行统一分配、调优和监控。
37.线程池的线程数量确定?状态分析?关闭方式?
更详细的见文章:对Java线程池ThreadPoolExecutor的理解分析
线程池的线程数量怎么确定
- 一般来说,如果是CPU密集型应用,则线程池大小设置为N+1。
- 一般来说,如果是IO密集型应用,则线程池大小设置为2N+1。
- 在IO优化中,线程等待时间所占比例越高,需要越多线程,线程CPU时间所占比例越高,需要越少线程。这样的估算公式可能更适合:最佳线程数目 = ((线程等待时间+线程CPU时间)/线程CPU时间 )* CPU数目
线程池的五种运行状态
RUNNING : 该状态的线程池既能接受新提交的任务,又能处理阻塞队列中任务。
SHUTDOWN:该状态的线程池**不能接收新提交的任务**,**但是能处理阻塞队列中的任务**。处于 RUNNING 状态时,调用 shutdown()方法 会使线程池进入到该状态。
注意: finalize() 方法在执行过程中也会隐式调用shutdown()方法。
STOP: 该状态的线程池不接受新提交的任务,也不处理在阻塞队列中的任务,还会中断正在执行的任务。在线程池处于 RUNNING 或 SHUTDOWN 状态时,调用 shutdownNow() 方法 会使线程池进入到该状态;
TIDYING: 如果所有的任务都已终止,workerCount (有效线程数)=0 。线程池进入该状态后会调用 terminated() 钩子方法 进入TERMINATED 状态。
TERMINATED: 在terminated()钩子方法执行完后进入该状态,默认terminated()钩子方法中什么也没有做。
线程池的关闭(shutdown或者shutdownNow方法)
可以通过调用线程池的shutdown或者shutdownNow方法来关闭线程池:遍历线程池中工作线程,逐个调用interrupt方法来中断线程。
shutdown方法与shutdownNow的特点:
- shutdown方法将线程池的状态设置为SHUTDOWN状态,只会中断空闲的工作线程。
- shutdownNow方法将线程池的状态设置为STOP状态,会中断所有工作线程,不管工作线程是否空闲。
- 调用两者中任何一种方法,都会使isShutdown方法的返回值为true;线程池中所有的任务都关闭后,isTerminated方法的返回值为true。
- 通常使用shutdown方法关闭线程池,如果不要求任务一定要执行完,则可以调用shutdownNow方法。
38.如何控制线程池线程的优先级
在Java中,线程的优先级可以通过设置线程的优先级属性来控制。线程池中的线程也可以通过设置优先级来调整其执行顺序。以下是设置线程池线程优先级的一般步骤:
创建线程池对象:首先,使用Executors类或ThreadPoolExecutor类创建一个线程池对象。
java
ExecutorService executor = Executors.newFixedThreadPool(10);
自定义线程工厂:通过实现ThreadFactory接口,自定义一个线程工厂类,用于创建线程对象并设置线程的优先级。
java
class CustomThreadFactory implements ThreadFactory {
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r);
t.setPriority(Thread.MAX_PRIORITY); // 设置线程优先级
return t;
}
}
创建线程池并设置线程工厂:使用自定义的线程工厂类创建线程池对象,并将其设置为线程池的线程工厂。
java
ExecutorService executor = Executors
.newFixedThreadPool(10, new CustomThreadFactory());
通过以上步骤,**线程池中的线程将使用自定义的线程工厂来创建,从而可以设置线程的优先级。**在上述示例中,将线程优先级设置为Thread.MAX_PRIORITY,也可以根据需求设置其他优先级,如Thread.MIN_PRIORITY或Thread.NORM_PRIORITY。
需要注意的是,线程的优先级并不是绝对的,它只是给调度器一个提示,告诉它线程的相对重要性。实际的线程调度行为还受到操作系统和底层硬件的影响。因此,不能过度依赖线程的优先级来控制程序的执行顺序和性能。
此外,需要注意的是,在使用线程池时,线程的优先级可能被线程池管理器调整,以便更好地管理线程的执行顺序和资源利用。因此,在设置线程池中线程的优先级时,需要结合具体的场景和需求来评估其影响。
39.核心线程池ThreadPoolExecutor的参数/常见线程池的创建参数是什么样的?(高频考点)
更详细的见文章:对Java线程池ThreadPoolExecutor的理解分析
可以通过ThreadPoolExecutor
来创建一个线程池,先上代码吧:
java
new ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime,
TimeUnit unit, BlockingQueue<Runnable> workQueue, RejectedExecutionHandler handler)
常用的5个,核心池、最大池、空闲时间、时间的单位、阻塞队列;另外两个:拒绝策略、线程工厂类
- corePoolSize:指定了线程池中的线程数量
- maximumPoolSize:指定了线程池中的最大线程数量
- keepAliveTime:线程池维护线程所允许的空闲时间
- unit: keepAliveTime 的单位。
- workQueue:任务队列,被提交但尚未被执行的任务。
- threadFactory:线程工厂,用于创建线程,一般用默认的即可。
- handler:拒绝策略。当任务太多来不及处理,如何拒绝任务。
具体详细说明:
corePoolSize(线程池的基本大小):
- 提交一个任务到线程池时,线程池会创建一个新的线程来执行任务。注意: 即使有空闲的基本线程能执行该任务,也会创建新的线程。
- 如果线程池中的线程数已经大于或等于corePoolSize,则不会创建新的线程。
- 如果调用了线程池的prestartAllCoreThreads()方法,线程池会提前创建并启动所有基本线程。
maximumPoolSize(线程池的最大数量): 线程池允许创建的最大线程数。
- 阻塞队列已满,线程数小于maximumPoolSize便可以创建新的线程执行任务。
- 如果使用无界的阻塞队列,该参数没有什么效果。
workQueue(工作队列): 用于保存等待执行的任务的阻塞队列。
- ArrayBlockingQueue: 基于数组结构的有界阻塞队列,按FIFO(先进先出)原则对任务进行排序。使用该队列,线程池中能创建的最大线程数为maximumPoolSize。
- LinkedBlockingQueue: 基于链表结构的有界阻塞队列,按FIFO(先进先出)原则对任务进行排序,吞吐量高于ArrayBlockingQueue。使用该队列,线程池中能创建的最大线程数为corePoolSize。静态工厂方法 Executor.newFixedThreadPool()使用了这个队列。
- SynchronousQueue: 一个不存储元素的阻塞队列。添加任务的操作必须等到另一个线程的移除操作,否则添加操作一直处于阻塞状态。静态工厂方法 Executor.newCachedThreadPool()使用了这个队列。
- PriorityBlokingQueue: 一个支持优先级的无界阻塞队列。使用该队列,线程池中能创建的最大线程数为corePoolSize。
keepAliveTime(线程活动保持时间): 线程池的工作线程空闲后,保持存活的时间。如果任务多而且任务的执行时间比较短,可以调大keepAliveTime,提高线程的利用率。
unit(线程活动保持时间的单位): 可选单位有DAYS、HOURS、MINUTES、毫秒、微秒、纳秒。
handler(饱和策略,或者又称拒绝策略): 当队列和线程池都满了,即线程池饱和了,必须采取一种策略处理提交的新任务。
- AbortPolicy: 无法处理新任务时,直接抛出异常,这是默认策略。
- CallerRunsPolicy:用调用者所在的线程来执行任务。
- DiscardOldestPolicy:丢弃阻塞队列中最靠前的一个任务,并执行当前任务。
- DiscardPolicy: 直接丢弃任务。
threadFactory: 构建线程的工厂类
40.new ThreadPoolExecutor(10,100,10,TimeUnit.MILLISECONDS,new LinkedBlockingQueue(10));一个这样创建的线程池,当已经有10个任务在运行时,第11个任务提交到此线程池执行的时候会发生什么,为什么?
在这样创建的线程池中,当已经有10个任务在运行时,第11个任务提交到此线程池执行时,会发生以下情况:
-
因为线程池的核心线程数为10,最大线程数为100,所以前10个任务会立即启动并被线程池中的10个核心线程执行。
-
第11个任务会被放入线程池的任务队列中,即
LinkedBlockingQueue
中。 -
由于任务队列的大小为10,而此时已经有10个任务在队列中等待执行,所以第11个任务会被成功添加到队列中。
-
如果任务队列被填满,并且当前线程池中的线程数量还未达到最大线程数(100),则会创建新的线程执行任务。
-
如果任务队列已满,并且当前线程池中的线程数量已经达到最大线程数(100),则新的任务将无法被提交到线程池中,并且会根据线程池的拒绝策略进行处理。默认情况下,线程池的默认拒绝策略是抛出
RejectedExecutionException
异常。
总之,当已经有10个任务在运行时,第11个任务提交到此线程池执行时,如果任务队列未满,则任务会被成功添加到队列中;如果任务队列已满,并且线程池中的线程数量已经达到最大线程数,则根据线程池的拒绝策略进行处理。
41.实现一个自定义的ThreadFactory的作用通常是?
实现一个自定义的 ThreadFactory
的作用通常包括以下几个方面:
-
命名线程 :自定义的
ThreadFactory
可以为线程设置有意义的名称,使得在日志和调试信息中能够清晰地识别线程的作用和来源。这样有助于跟踪线程的执行情况和定位问题。 -
设置线程属性 :通过自定义的
ThreadFactory
,可以为线程设置一些属性,如线程的优先级、是否为守护线程等,以满足特定的需求。 -
创建定制化的线程 :自定义的
ThreadFactory
可以根据应用的需求创建定制化的线程,如自定义的异常处理器、线程组等,以增强线程的管理和控制能力。 -
封装线程创建过程 :通过自定义的
ThreadFactory
,可以封装线程的创建过程,使得应用代码与线程创建逻辑解耦,提高代码的可维护性和可扩展性。 -
统一管理线程 :使用自定义的
ThreadFactory
可以统一管理应用中所有线程的创建,集中处理线程的创建逻辑和管理策略,方便进行统一的管理和调整。
总的来说,自定义的 ThreadFactory
主要作用是为了提供更加灵活和定制化的线程创建和管理功能,使得应用能够更好地满足特定的需求,并提高线程的可观察性、可控性和可维护性。
42.常见的线程池创建和参数分析?(高频考点)
更详细的见文章:对Java线程池ThreadPoolExecutor的理解分析
常见的线程池创建主要依赖于 java.util.concurrent
包提供的 Executors
工厂类,同时需要根据任务性质和工作负载来选择合适的线程池参数。以下是一些常见线程池的创建和参数分析:1. FixedThreadPool(固定大小的线程池):
java
ExecutorService executor = Executors.newFixedThreadPool(5);
- 固定大小的线程池,核心线程数和最大线程数相等。
- 适用于处理固定数量的任务,控制并发线程数。
- CachedThreadPool(缓存线程池):
java
ExecutorService executor = Executors.newCachedThreadPool();
- 核心线程数为 0,最大线程数不限制。
- 适用于处理大量短时间的任务,线程数量根据任务动态调整。
- SingleThreadExecutor(单一线程池):
java
ExecutorService executor = Executors.newSingleThreadExecutor();
- 只有一个核心线程,确保所有任务按顺序执行。
- 适用于需要顺序执行任务的场景。
- ScheduledThreadPool(定时任务线程池):
java
ScheduledExecutorService scheduledExecutor = Executors.newScheduledThreadPool(3);
- 适用于需要定时执行任务的场景,可以延迟执行或定期执行。
- ThreadPoolExecutor(自定义线程池):
java
ThreadPoolExecutor executor = new ThreadPoolExecutor(
corePoolSize, // 核心线程数
maximumPoolSize, // 最大线程数
keepAliveTime, // 线程空闲时间
TimeUnit.SECONDS, // 时间单位
new LinkedBlockingQueue<>(), // 工作队列
Executors.defaultThreadFactory(), // 线程工厂
new ThreadPoolExecutor.AbortPolicy() // 拒绝策略
);
corePoolSize
:核心线程数,线程池维护的最小线程数。maximumPoolSize
:最大线程数,线程池维护的最大线程数。keepAliveTime
:线程空闲时间,非核心线程在空闲时的最大存活时间。TimeUnit
:时间单位。workQueue
:工作队列,存储未执行任务的队列。threadFactory
:线程工厂,用于创建线程。handler
:拒绝策略,用于处理任务无法被执行的情况。
参数选择注意事项
- 核心线程数和最大线程数的选择要根据任务的性质和系统资源进行权衡。
workQueue
的选择要根据任务提交速度和处理速度的差异,选择合适的阻塞队列。- 线程空闲时间的设置要根据系统负载和任务性质进行调整。
- 拒绝策略的选择要根据业务需求,例如抛出异常、丢弃任务、调用者运行等。
不同的线程池适用于不同的场景,根据具体需求进行选择。在实际应用中,参数的调优通常需要结合系统资源状况和任务的特性进行综合考虑。
43.ThreadPoolExecutor的工作流程(高频考点)
更详细的见文章:对Java线程池ThreadPoolExecutor的理解分析
基本背景思路:
一个新的任务到线程池时,线程池的处理流程如下:
- 线程池判断核心线程池里的线程是否都在执行任务。 如果不是,创建一个新的工作线程来执行任务。如果核心线程池里的线程都在执行任务,则进入下个流程。
- 线程池判断阻塞队列是否已满。 如果阻塞队列没有满,则将新提交的任务存储在阻塞队列中。如果阻塞队列已满,则进入下个流程。
- 线程池判断线程池里的线程是否都处于工作状态。 如果没有,则创建一个新的工作线程来执行任务。如果已满,则交给饱和策略来处理这个任务。
ThreadPoolExecutor类
具体的处理流程:
线程池的核心实现类是ThreadPoolExecutor类
,用来执行提交的任务。因此,任务提交到线程池时,具体的处理流程是由ThreadPoolExecutor类
的execute()方法去完成的。
- 如果当前运行的线程少于corePoolSize,则创建新的工作线程来执行任务(执行这一步骤需要获取全局锁)。
- 如果当前运行的线程大于或等于corePoolSize,而且BlockingQueue未满,则将任务加入到BlockingQueue中。
- 如果BlockingQueue已满,而且当前运行的线程小于maximumPoolSize,则创建新的工作线程来执行任务(执行这一步骤需要获取全局锁)。
- 如果当前运行的线程大于或等于maximumPoolSize,任务将被拒绝,并调用RejectExecutionHandler.rejectExecution()方法。即调用饱和策略对任务进行处理。
44.ScheduledThreadPoolExecutor中的使用的是什么队列?内部如何实现任务排序的?
ScheduledThreadPoolExecutor
继承自ThreadPoolExecutor,使用的工作队列是 DelayedWorkQueue
。DelayedWorkQueue
是 DelayedQueue
的一个实现,它继承自 AbstractQueue
,而后者实现了基本的队列操作。
在 ScheduledThreadPoolExecutor
中,DelayedWorkQueue
用于存储实现了 ScheduledFuture
接口的任务,其中的任务具有延迟执行或定期执行的特性。这样的任务会按照它们的延迟时间或周期进行排序。
内部任务排序机制
按照延迟时间排序:
DelayedWorkQueue
内部维护了一个有序的优先级队列(PriorityQueue),按照任务的延迟时间进行排序。- 在插入任务时,根据任务的延迟时间将任务放入队列的适当位置。
按照周期性任务的下次执行时间排序:
- 对于周期性任务(即有固定的执行周期),在任务执行完一次后,会根据下次执行的时间重新计算延迟时间,然后放回队列的适当位置。
- 这样就保证了周期性任务按照下次执行时间的顺序进行排序。
通过这种排序机制,ScheduledThreadPoolExecutor
能够按照任务的延迟时间或者下次执行时间来执行任务,确保任务按照预期的时间顺序执行。
值得注意的是,DelayedWorkQueue
作为有界队列,可以配置最大容量,以控制任务的排队数量。如果队列已满,新的任务可能会导致拒绝策略的触发。
45.线程池的运行逻辑,FixedThreadPool、CachedThreadPool的原理(高频考点)
线程池是一种用于管理和调度线程的机制,它可以有效地管理线程的创建、执行和销毁。下面是线程池的运行逻辑以及FixedThreadPool和CachedThreadPool的原理。
线程池的运行逻辑:
- 初始化线程池,设置线程池的核心线程数、最大线程数、任务队列等参数。
- 当有任务提交给线程池时,线程池会按照以下规则执行:a. 如果当前运行的线程数小于核心线程数,创建新的线程来执行任务。b. 如果当前运行的线程数等于核心线程数,将任务放入任务队列等待执行。c. 如果任务队列已满且当前运行的线程数小于最大线程数,创建新的线程来执行任务。d. 如果任务队列已满且当前运行的线程数达到最大线程数,根据线程池的拒绝策略来处理任务。
- 当线程执行完一个任务后,它会从任务队列中获取下一个任务进行执行,直到线程池关闭或出现异常。
- 如果线程池闲置一段时间(根据具体实现而定),超过预设的存活时间,额外的线程会被终止,以节省资源。
- 线程池可以通过调整核心线程数、最大线程数和任务队列等参数进行灵活的配置和优化。
FixedThreadPool的原理:
FixedThreadPool是一种固定大小的线程池,它会在初始化时创建指定数量的线程,并且线程数不会改变。它的原理是:
- 初始化FixedThreadPool时,创建指定数量的线程,即核心线程数和最大线程数都是固定的。
- 当有任务提交给FixedThreadPool时,如果有空闲线程,则立即分配线程来执行任务。
- 如果所有线程都在执行任务且任务队列已满,则新任务将被阻塞,直到有线程空闲或任务队列有空闲位置。
- 因为FixedThreadPool的线程数是固定的,所以它适合于需要固定线程数的场景,例如需要控制资源消耗或并发度的应用。
CachedThreadPool的原理:
CachedThreadPool是一种根据需要自动调整线程数量的线程池,它的原理是:
- 初始化CachedThreadPool时,不会创建任何线程。
- 当有任务提交给CachedThreadPool时,它会尝试重用之前空闲的线程,如果有可用的空闲线程,则立即分配线程来执行任务。
- 如果所有线程都在执行任务且任务队列已满,则会创建新的线程来处理新的任务。
- 如果某个线程在一段时间内没有任务可执行,它将被终止并从线程池中移除,以节省资源。
- 当新的任务提交给CachedThreadPool时,如果之前终止的线程数量不超过最大线程数,那么会重新使用之前终止的线程来执行任务,而不是创建新的线程。
- CachedThreadPool会根据任务的数量和执行时间的情况自动调整线程数量,增加线程以处理更多的任务,减少线程以释放闲置的资源。
- CachedThreadPool适用于任务量不固定、需要快速响应并且执行时间较短的场景,可以根据需求动态调整线程数量,以提高线程的利用率和系统的响应能力。
总而言之,FixedThreadPool和CachedThreadPool是两种常见的线程池实现。FixedThreadPool适用于需要固定线程数的场景,而CachedThreadPool适用于任务量不确定的场景,它会根据需求动态调整线程数量以提高系统的性能。
46.用Executors.newCachedThreadPool创建的线程池,在运行的过程中有可能产生的风险是?
使用Executors.newCachedThreadPool()
创建的线程池是一个可缓存的线程池,它会根据需要动态地创建新线程,如果线程在60秒内未被使用就会被终止并从池中移除。虽然这种线程池具有灵活性和高效性,但也存在一些潜在的风险和问题:
-
线程数量不受限制 :
newCachedThreadPool()
创建的线程池没有固定的线程数限制,理论上可以创建大量的线程,如果并发请求过多,可能会导致服务器资源不足,出现内存溢出或者CPU过载等问题。 -
长时间运行的任务可能导致内存泄漏:由于线程池会在一定时间内清理未使用的线程,长时间运行的任务可能会导致线程被长时间占用,无法及时回收,从而导致内存泄漏。
-
任务执行时间不可控:线程池中的线程数量是动态调整的,任务的执行时间可能受到线程池中其他任务的影响,如果某些任务执行时间较长,可能会影响其他任务的执行效率。
-
可能导致任务排队过多:当任务提交速度大于线程池处理速度时,任务会被放入任务队列中等待执行,如果任务队列过长,可能会导致系统资源耗尽,造成系统性能下降或者宕机。
-
线程生命周期不受控制:由于线程池中的线程是可缓存的,因此线程的生命周期由线程池管理,无法手动控制线程的生命周期,可能会导致资源管理不当或者任务执行异常处理不及时。
基于以上风险,使用newCachedThreadPool()
时需要注意合理调整任务提交速度和任务执行时间,避免出现资源不足或者任务排队过多的情况,同时需要注意及时处理长时间运行的任务和异常情况,以保障系统的稳定性和性能。
47.阻塞队列ArrayBlockingQueue、LinkedBlockingQueue分析(高频考点)
ArrayBlockingQueue和LinkedBlockingQueue都是Java中常见的阻塞队列实现,它们都提供了线程安全的队列操作,并且支持在队列为空或已满时的阻塞操作。
ArrayBlockingQueue
- ArrayBlockingQueue是一个基于数组的有界阻塞队列,它的容量在创建时被固定。
- 内部使用一个固定大小的数组来存储元素,因此在添加或移除元素时具有较高的效率。
- 当尝试将元素添加到已满的队列中时,操作将被阻塞,直到队列有空闲位置可用。
- 当尝试从空队列中移除元素时,操作也将被阻塞,直到队列中有元素可供移除。
- ArrayBlockingQueue的阻塞操作是通过使用内置的锁和条件变量实现的。
LinkedBlockingQueue
- LinkedBlockingQueue是一个基于链表的可选界限阻塞队列,它可以选择在创建时设置容量上限,如果未指定容量,则默认为无界队列。
- 内部使用链表来存储元素,因此可以动态调整大小,并且没有固定容量的限制。
- 当尝试将元素添加到已满的有界队列中时,操作将被阻塞,直到队列有空闲位置可用。
- 当尝试从空队列中移除元素时,操作也将被阻塞,直到队列中有元素可供移除。
- LinkedBlockingQueue的阻塞操作是通过使用内置的锁和条件变量实现的。
两者的选择
- 如果你需要一个有界队列,并且在队列已满时阻塞添加操作或队列为空时阻塞移除操作,可以选择ArrayBlockingQueue。
- 如果你需要一个可以动态调整大小的队列,或者希望使用默认的无界队列,可以选择LinkedBlockingQueue。
需要注意的是,无界队列可能会在持续添加元素时耗尽系统的内存资源,因此在选择队列实现时要根据场景和需求进行权衡和选择。
48.请合理的使用Queue来实现一个高并发的生产/消费的场景,给些核心的代码片段。
CustomProducerConsumer
类封装了生产者消费者模型的实现细节,使用了 wait()
和 notifyAll()
方法来实现线程之间的等待和通知。生产者通过调用 produce()
方法往缓冲区中放入数据,消费者通过调用 consume()
方法从缓冲区中取出数据。
java
import java.util.LinkedList;
import java.util.Queue;
class CustomProducerConsumer {
private final Queue<Integer> buffer;
private final int capacity;
public CustomProducerConsumer(int capacity) {
this.capacity = capacity;
this.buffer = new LinkedList<>();
}
public void produce(int value) throws InterruptedException {
synchronized (this) {
while (buffer.size() == capacity) {
wait();
}
buffer.offer(value);
System.out.println("Produced: " + value);
notifyAll();
}
}
public int consume() throws InterruptedException {
synchronized (this) {
while (buffer.isEmpty()) {
wait();
}
int value = buffer.poll();
System.out.println("Consumed: " + value);
notifyAll();
return value;
}
}
}
class Producer implements Runnable {
private final CustomProducerConsumer pc;
public Producer(CustomProducerConsumer pc) {
this.pc = pc;
}
@Override
public void run() {
for (int i = 0; i < 10; i++) {
try {
pc.produce(i);
Thread.sleep(100); // 模拟生产时间
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
class Consumer implements Runnable {
private final CustomProducerConsumer pc;
public Consumer(CustomProducerConsumer pc) {
this.pc = pc;
}
@Override
public void run() {
for (int i = 0; i < 10; i++) {
try {
int value = pc.consume();
Thread.sleep(200); // 模拟消费时间
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class Main {
public static void main(String[] args) {
CustomProducerConsumer pc = new CustomProducerConsumer(5); // 缓冲区容量为5
Thread producerThread = new Thread(new Producer(pc));
Thread consumerThread = new Thread(new Consumer(pc));
producerThread.start();
consumerThread.start();
}
}
49.线程池关闭原理
线程池的关闭原理涉及到线程池的生命周期管理和任务处理的终止过程。下面是线程池关闭的一般原理:
- 停止接收新任务:首先,线程池需要停止接收新的任务提交。可以通过调用线程池的 shutdown() 方法来实现。此时,线程池将拒绝新的任务提交,并且不会再接受新的任务。
- 处理已提交的任务:一旦停止接收新的任务,线程池会继续处理已提交的任务。已经在等待队列中的任务将继续执行,正在执行的任务也会继续执行,直到所有的任务都执行完毕。
- 清空等待队列:在处理完已提交的任务后,线程池会尝试清空等待队列中的任务。可以通过调用线程池的 shutdownNow() 方法来实现。该方法将会尝试终止所有的任务,并返回等待队列中未执行的任务列表。
- 终止线程池:一旦等待队列中的任务被清空,线程池将会彻底终止。此时,所有的工作线程都将停止,并且线程池的状态将被标记为已终止。
需要注意的是,线程池的关闭并不会立即停止所有的任务。已经在执行的任务需要等待其执行完成,而等待队列中的任务可以选择是否继续等待执行或者被丢弃。同时,如果线程池中的任务存在依赖关系,需要注意任务之间的处理顺序,以免产生不可预期的结果。
在使用线程池时,建议在合适的时机进行关闭操作,以确保资源的正确释放和程序的正常终止。可以通过适当的方式监听线程池的关闭状态,以便在需要时进行后续的处理。
50.JUC下的常见类的使用,take、poll的区别,put、offer的区别?
在 Java 并发编程中,JUC(Java Util Concurrent)包提供了一些常见的类,例如 BlockingQueue
接口和其实现类 ArrayBlockingQueue
、LinkedBlockingQueue
、PriorityBlockingQueue
等。这些类通常用于多线程之间的数据交换和协调。
take()
和 poll()
方法的区别:
take()
方法用于从队列中取出元素,如果队列为空,则会阻塞线程,直到队列中有元素可以取出。poll()
方法也用于从队列中取出元素,如果队列为空,则会立即返回null
,不会阻塞线程。
put()
和 offer()
方法的区别:
put()
方法用于向队列中添加元素,如果队列已满,则会阻塞线程,直到队列有空闲位置可以添加元素。offer()
方法也用于向队列中添加元素,如果队列已满,则会立即返回false
,不会阻塞线程。
简要总结一下:
take()
和put()
方法是阻塞的,会等待队列状态满足条件再执行。poll()
和offer()
方法是非阻塞的,立即返回结果,不会等待队列状态改变。
51.Future原理,其局限性是什么?并说说CompletableFuture核心原理?
更详细的见文章:CompletableFuture回调机制的设计与实现
Future的实现原理就是通过Future和FutureTask接口,将任务封装成一个异步操作,并在主线程中等待任务完成后获取执行结果。FutureTask是Future的一个具体实现,通过阻塞方法和回调函数来实现异步操作的结果获取。
虽然Future在Java中提供了一种简单的异步编程技术,但它也存在一些局限性,包括以下几个方面:
- 阻塞问题:Future的get()方法是一个阻塞方法,如果任务没有完成,会一直阻塞当前线程,这会导致整个应用程序的响应性下降。
- 无法取消任务:Future的cancel()方法可以用于取消任务的执行,但如果任务已经开始执行,则无法取消。此时只能等待任务执行完毕,这会导致一定的性能损失。
- 缺少异常处理:Future的get()方法会抛出异常,但是如果任务执行过程中抛出异常,Future无法处理异常,只能将异常抛给调用者处理。
- 缺少组合操作:Future只能处理单个异步操作,无法支持多个操作的组合,例如需要等待多个任务全部完成后再执行下一步操作。
综上所述,Future虽然提供了一种简单的异步编程技术,但它的局限性也是比较明显的。在实际应用中,我们需要根据具体的业务需求和性能要求,选择合适的异步编程技术。例如,可以使用CompletableFuture来解决Future的一些问题,它可以避免阻塞、支持异常处理和组合操作等功能。
CompletableFuture原理总述与回调机制总结
CompletableFuture是Java 8中引入的一个强大的异步编程工具,它允许我们以非阻塞的方式处理异步操作,并通过回调函数来处理异步操作完成后的结果。
**CompletableFuture的核心原理是基于Java的Future接口和内部的状态机实现的。**它可以通过三个步骤来实现异步操作:
- 创建CompletableFuture对象:通过CompletableFuture的静态工厂方法,我们可以创建一个新的CompletableFuture对象,并指定该对象的异步操作。通常情况下,我们可以通过supplyAsync()或者runAsync()方法来创建CompletableFuture对象。
- 异步操作的执行:在CompletableFuture对象创建之后,异步操作就开始执行了。这个异步操作可以是一个计算任务或者一个IO操作。CompletableFuture会在另一个线程中执行这个异步操作,这样主线程就不会被阻塞。
- 对异步操作的处理:异步操作执行完成后,CompletableFuture会根据执行结果修改其内部的状态,并触发相应的回调函数。如果异步操作成功完成,则会触发CompletableFuture的完成回调函数;如果异步操作抛出异常,则会触发CompletableFuture的异常回调函数。
CompletableFuture的优势在于它支持链式调用和组合操作。通过CompletableFuture的then系列方法,我们可以创建多个CompletableFuture对象,并将它们串联起来形成一个链式的操作流。在这个操作流中,每个CompletableFuture对象都可以依赖于之前的CompletableFuture对象,以实现更加复杂的异步操作。
总的来说,CompletableFuture的原理是基于Java的Future接口和内部的状态机实现的,它可以以非阻塞的方式执行异步操作,并通过回调函数来处理异步操作完成后的结果。通过链式调用和组合操作,CompletableFuture可以方便地实现复杂的异步编程任务。
**52.**你是否了解fork/join(基本思想)?在工作中是如何使用的?说说他们的优势是什么?(高频考点)
基本内容的了解
Fork/Join 框架是 Java 并发编程中的一个重要工具,用于实现分治任务的并行执行。在工作中可以使用 Fork/Join 框架来解决一些需要并行计算的问题,比如大规模数据的排序、搜索和归约等任务。
Fork/Join 框架的核心是基于工作窃取(Work Stealing)算法实现的。这个算法的基本思想是让空闲的线程从其他线程的任务队列中窃取任务来执行,以达到任务的动态负载均衡。
Fork/Join 框架主要包括以下几个核心组件:
-
工作线程(Worker Thread): 每个工作线程都有自己的任务队列(双端队列),用于存放待执行的任务。当一个线程执行完自己任务队列中的任务后,它会尝试从其他线程的任务队列中窃取任务来执行。
-
任务(Task): 任务是 Fork/Join 框架中的基本执行单元。通常使用
RecursiveTask
或RecursiveAction
类来表示任务,分别用于有返回值和无返回值的任务。任务可以递归地分解成更小的子任务,直到达到某个阈值后停止分解。 -
工作窃取(Work Stealing): 当一个线程的任务队列为空时,它会尝试从其他线程的任务队列中窃取任务来执行。这种机制能够充分利用多核 CPU 的计算资源,提高任务的并行度和执行效率。
-
线程池(ForkJoinPool): Fork/Join 框架通过
ForkJoinPool
类来管理和调度线程。线程池中的每个线程都是一个工作线程,它们会从任务队列中获取任务来执行,并且可以相互之间进行工作窃取。
总的来说,Fork/Join 框架利用工作窃取算法实现了任务的动态负载均衡,通过递归地划分任务并利用多线程并行执行任务,从而提高了并发程序的执行效率。
具体使用示例
当使用 Fork/Join 框架时,一般需要以下几个步骤:
-
定义任务类(RecursiveTask 或 RecursiveAction): 首先需要定义一个继承自
RecursiveTask
或RecursiveAction
的任务类,具体取决于任务是否有返回值。在任务类中,需要实现compute()
方法来执行实际的任务逻辑。 -
划分任务(拆分): 在
compute()
方法中,需要根据具体的业务逻辑来划分任务,将大任务拆分成多个小任务。这通常涉及到递归地划分任务,直到任务达到某个阈值时停止拆分。 -
执行任务: 使用 Fork/Join 框架的
ForkJoinPool
类来执行任务。创建一个ForkJoinPool
实例,并调用其invoke()
方法来执行根任务。 -
合并结果(如果有返回值的话): 如果任务有返回值,则需要在根任务执行完成后,合并各个子任务的结果。通常是在
compute()
方法中进行结果的合并操作。
以下是一个简单的示例,演示了如何使用 Fork/Join 框架来计算斐波那契数列的值:
java
import java.util.concurrent.RecursiveTask;
import java.util.concurrent.ForkJoinPool;
class FibonacciTask extends RecursiveTask<Integer> {
private final int n;
public FibonacciTask(int n) {
this.n = n;
}
@Override
protected Integer compute() {
if (n <= 1) {
return n;
} else {
FibonacciTask task1 = new FibonacciTask(n - 1);
FibonacciTask task2 = new FibonacciTask(n - 2);
task1.fork(); // 异步执行子任务1
return task2.compute() + task1.join(); // 同步执行子任务2,同时等待子任务1完成
}
}
public static void main(String[] args) {
ForkJoinPool pool = new ForkJoinPool();
FibonacciTask task = new FibonacciTask(10);
int result = pool.invoke(task);
System.out.println("Result: " + result);
}
}
说说优势
-
高性能并行计算: Fork/Join 框架采用了工作窃取算法,能够有效地利用多核 CPU 的计算资源,提高任务的并行度和执行效率。通过将大任务分解成多个小任务,并在多个线程之间动态地分配和执行任务,Fork/Join 框架能够充分利用系统的计算资源,实现高性能的并行计算。
-
简化并发编程: Fork/Join 框架提供了高层次的抽象,使得开发者可以更轻松地编写并发程序。通过将任务的分解、执行和合并等细节封装在框架中,开发者可以专注于业务逻辑的实现,而无需关注线程的管理和同步等底层细节,从而简化了并发编程的复杂性。
-
任务的动态调度与管理: Fork/Join 框架提供了自动线程管理的功能,能够根据系统的负载和任务的执行情况动态地调整线程池的大小,从而避免了线程过多或过少导致的资源浪费或性能下降问题。此外,Fork/Join 框架还提供了一些监控和调优的工具,可以帮助开发者更好地管理和优化并发任务的执行。
53.Java线程池的调优经验有哪些?(线程池的合理配置)(高频考点)
更详细的见文章:对Java线程池ThreadPoolExecutor的理解分析
这里直接推荐使用动态线程池配置和监控更加符合业务要求,具体见上述博客!
从以下几个角度分析任务的特性:
- 任务的性质:
CPU 密集型任务
、IO 密集型任务
和混合型任务
。 - 任务的优先级: 高、中、低。
- 任务的执行时间: 长、中、短。
- 任务的依赖性:
是否依赖其他系统资源
,如数据库连接
。
任务性质不同的任务可以用不同规模的线程池分开处理。 可以通过 Runtime.getRuntime().availableProcessors() 方法获得当前设备的 CPU 个数。
- CPU 密集型任务配置尽可能小的线程,如配置 个线程的线程池。
- IO 密集型任务则由于线程并不是一直在执行任务,则配置尽可能多的线程,如 。
- 混合型任务,如果可以拆分,则将其拆分成一个 CPU 密集型任务和一个 IO 密集型任务。只要这两个任务执行的时间相差不是太大,那么分解后执行的吞吐率要高于串行执行的吞吐率;如果这两个任务执行时间相差太大,则没必要进行分解。
优先级不同的任务可以使用优先级队列 PriorityBlockingQueue 来处理,它可以让优先级高的任务先得到执行。但是,如果一直有高优先级的任务加入到阻塞队列中,那么低优先级的任务可能永远不能执行。
执行时间不同的任务可以交给不同规模的线程池来处理,或者也可以使用优先级队列,让执行时间短的任务先执行。
依赖数据库连接池的任务,因为线程提交 SQL 后需要等待数据库返回结果,线程数应该设置得较大,这样才能更好的利用 CPU。
建议使用有界队列,有界队列能增加系统的稳定性和预警能力。可以根据需要设大一点,比如几千。使用无界队列,线程池的队列就会越来越大,有可能会撑满内存,导致整个系统不可用。
怎么对线程池进行有效监控?
以通过线程池提供的参数读线程池进行监控,有以下属性可以使用:
- taskCount:线程池需要执行的任务数量,包括已经执行完的、未执行的和正在执行的。
- completedTaskCount:线程池在运行过程中已完成的任务数量,completedTaskCount <= taskCount。
- largestPoolSize:线程池曾经创建过的最大线程数量,通过这个数据可以知道线程池是否满过。如等于线程池的最大大小,则表示线程池曾经满了。
- getPoolSize: 线程池的线程数量。如果线程池不销毁的话,池里的线程不会自动销毁,所以线程池的线程数量只增不减。
- getActiveCount:获取活动的线程数。
通过继承线程池并重写线程池的 beforeExecute,afterExecute 和 terminated 方法,我们可以在任务执行前,执行后和线程池关闭前干一些事情。
54.一个请求中,计算操作需要50ms,db操作需要100ms,对于一台8核的机器来说,如果要求cpu利用率达到100%,如何设置线程数?(高频考点)
要求 CPU 利用率达到 100% 并不意味着要将所有的 CPU 核心都使用起来,而是要确保 CPU 在处理任务时始终处于繁忙状态,尽量避免空闲。对于这个情况,计算操作需要 50ms,DB 操作需要 100ms,我们可以考虑使用以下的计算方式来确定线程数:
计算 CPU 密集型任务需要的线程数:
- CPU 密集型任务的线程数可以根据 CPU 核心数来决定,通常可以选择与 CPU 核心数相等的线程数。
- 在一台 8 核的机器上,如果要求 CPU 利用率达到 100%,则可以选择使用 8 个线程。
计算 IO 密集型任务需要的线程数:
- IO 密集型任务(例如 DB 操作)由于需要等待 IO 操作的完成,因此会出现线程阻塞的情况。
- 一般来说,IO 密集型任务的线程数可以设置为 CPU 核心数的倍数,以确保 CPU 在等待 IO 操作完成时能够处理其他任务。
- 在一台 8 核的机器上,可以尝试设置为 2 * 8 = 16 个线程,以充分利用 CPU 的处理能力。
因此,在这种情况下,可以将线程池的线程数设置为 16,以确保 CPU 在执行计算操作时能够充分利用,而在执行 DB 操作时能够保持线程不阻塞。同时,需要根据实际情况进行性能测试和调优,以确定最佳的线程数。
55.如果系统中不同的请求对应的cpu时间和io时间都不同,那怎么设置线程数量?(高频考点)
如果系统中不同的请求对应的 CPU 时间和 IO 时间都不同,那么可以根据系统的性能特点和负载情况,动态地调整线程池的大小来适应不同请求的处理需求。下面是一些思路:
基于负载情况动态调整:
- 可以根据系统的负载情况动态地调整线程池的大小。
- 当系统负载较低时,可以适当减少线程池的大小,以节省资源。
- 当系统负载较高时,可以增加线程池的大小,以处理更多的请求。
使用弹性线程池:
- 弹性线程池可以根据任务队列的长度和负载情况动态地调整线程池的大小。
- 当任务队列中的任务数量超过一定阈值时,可以增加线程池的大小。
- 当任务队列中的任务数量降低时,可以减少线程池的大小。
根据请求类型和预估时间设置线程池参数:
- 可以根据不同请求类型和预估的 CPU 时间和 IO 时间设置不同的线程池。
- 对于 CPU 密集型任务,可以设置较大的核心线程数,以确保 CPU 能够充分利用。
- 对于 IO 密集型任务,可以设置较大的最大线程数,以允许更多的线程等待 IO 完成。
监控和性能测试:
- 可以通过监控系统的运行情况和进行性能测试,收集系统的负载数据和响应时间等指标。
- 根据监控数据和性能测试结果,调整线程池的大小和参数,以满足系统的性能需求。
总的来说,针对不同的请求类型和预估的处理时间,可以采用动态调整线程池大小的策略来适应不同请求的处理需求,从而最大化地利用系统资源并保持良好的性能。
56.线程池核心数20,最大600,阻塞队列200,当QPS200(注意是qps)的时候,请求是调第三方阻塞超时,请问怎么提高它的吞吐量(注意不能加机器)?(高频考点)
根据条件,线程池的核心线程数为 20,最大线程数为 600,阻塞队列大小为 200,当 QPS 达到 200 时出现请求阻塞和超时的情况。
在不能增加机器的情况下分析具体原因如下:
- **线程池参数设置不合理:**当 QPS 达到 200 时,线程池的核心线程数和最大线程数都可能不足以处理这么多请求。如果请求处理时间较长,会导致线程池中的线程被占用,无法处理后续请求,从而导致请求阻塞和超时。
- **阻塞队列满导致请求被拒绝:**当线程池的核心线程数和最大线程数都已满,且阻塞队列也已满时,新的请求将被拒绝,导致请求阻塞和超时。但当前的状态下,QPS 达到 200,阻塞队列大小为 200,核心线程数为 20,队列应该是不满的,但不能排除需要增加埋点再次分析一下最好;
- **第三方调用导致的阻塞:**如果请求涉及到调用第三方服务,且第三方服务响应时间较长或者不稳定,会导致请求阻塞。当线程池中的线程都被阻塞等待第三方服务响应时,无法处理后续请求,从而导致请求超时。
基于以上分析,可以采取以下措施来提高系统的吞吐量:
- **调整线程池参数:**可以尝试增大线程池的核心线程数和最大线程数,以及扩大阻塞队列的大小,以适应更高的负载。当然是在机器参数满足的条件下进行。
- **优化第三方调用:**对于耗时较长的第三方调用,可以采用异步调用或者连接池等方式来优化,减少请求阻塞时间。
- **减少线程阻塞时间:**对于可能导致线程阻塞的业务逻辑,进行优化,尽量减少线程的等待时间,以提高线程的利用率。
通过以上优化措施,可以提高系统的吞吐量,减少请求的阻塞和超时现象。
57.当前线程池是200,线程单次处理请求20ms,那么理论上单节点的qps 是多少呢?
理论 QPS(Queries Per Second,每秒查询次数)是衡量系统性能的一个重要指标,它表示在一秒钟内系统能够处理的查询或请求的数量。对于单节点系统,你可以使用以下公式来计算理论 QPS:
QPS = 1 / 平均请求响应时间
其中,平均请求响应时间是指系统从接收请求到完成响应的平均时间。这个时间通常以毫秒(ms)为单位。
注意:这个公式是一个理论上的近似值,实际的 QPS 可能会受到多种因素的影响,包括系统的硬件性能、软件优化、负载、并发性等等。
假设你有一个线程池,其中包含了 N 个线程,而平均每个请求的响应时间仍然是 T 毫秒。在这种情况下,你可以使用以下公式来计算理论 QPS:
QPS = N / T
这里,N 是线程池中的线程数量,T 是平均请求响应时间(以毫秒为单位)。
线程池能够提高并发处理能力,因此你可以同时处理更多的请求,这会影响到系统的理论 QPS。但仍然要注意,线程池的性能也会受到线程数量、线程调度、任务分配等因素的影响。因此,你在使用线程池的情况下,仍然需要进行性能测试和分析,以确定系统的实际性能情况。
根据提供的信息,当前线程池大小为 200,每个线程处理一个请求的时间为 20 毫秒。可以使用以下公式来计算理论上的单节点 QPS:
QPS = 线程池大小 / 单线程处理时间
将你提供的值代入公式:
QPS = 200 / (20 ms) = 200 / 0.02 s = 10000 QPS
理论上,单节点的 QPS 可以达到 10000。
然而,这个计算是基于理论情况下的近似值。在实际应用中,系统性能可能会受到多个因素的影响,包括线程调度、并发情况、硬件性能等。因此,在实际场景中,要进行性能测试和实际负载情况下的测试,以确定系统的实际性能和 QPS。
58.多线程对Long数据进行加和会存在什么问题?如何解决?
在多线程环境下对`Long`数据进行加和会存在并发安全性问题,主要涉及以下两个方面:
- 数据竞争:多个线程同时对同一个`Long`类型的数据进行加和操作时,可能会发生数据竞争,导致结果不正确。因为`Long`类型是64位的,而多线程的并发操作可能在同一时刻读取和写入数据,从而造成数据不一致性和错误的计算结果。
- 原子性问题:`Long`类型的加和操作不是原子性的,即不能在一个单独的操作中完成。它涉及读取当前值、加和操作、写入结果,这些操作之间可能被其他线程打断,从而导致不正确的计算结果。
解决这些问题的方法通常是使用原子操作或加锁来保证数据的正确性和一致性。Java提供了多种解决方案,其中一种常见的做法是使用`AtomicLong`类来进行原子操作:
java
import java.util.concurrent.atomic.AtomicLong;
public class AtomicLongExample {
private AtomicLong sum = new AtomicLong(0L);
public void addToSum(long value) {
sum.addAndGet(value);
}
public long getSum() {
return sum.get();
}
public static void main(String[] args) throws InterruptedException {
final AtomicLongExample example = new AtomicLongExample();
final int threadCount = 10;
final int iterations = 100000;
Runnable task = () -> {
for (int i = 0; i < iterations; i++) {
example.addToSum(1L);
}
};
Thread[] threads = new Thread[threadCount];
for (int i = 0; i < threadCount; i++) {
threads[i] = new Thread(task);
threads[i].start();
}
for (Thread thread : threads) {
thread.join();
}
System.out.println("Final sum: " + example.getSum());
}
}
在上述示例中,使用`AtomicLong`来保证对`sum`的加和操作是原子的,从而避免了数据竞争和不正确的计算结果。
另外一种解决方案是使用锁(如`synchronized`关键字或`ReentrantLock`)来保护对`Long`数据的并发访问,确保每次只有一个线程能够对数据进行操作,从而保证数据的一致性和正确性。这样的做法虽然可以解决并发安全问题,但在高并发情况下可能会引起性能问题,因为锁会导致线程竞争和阻塞。因此,在选择解决方案时需要根据具体场景和需求来权衡利弊。
59.Netty 的线程机制是什么样的?
Netty是一个基于Java的异步事件驱动的网络应用框架,它的线程机制主要涉及两个方面:EventLoopGroup和EventLoop。
EventLoopGroup
- EventLoopGroup是一个线程池,它包含一组EventLoop。
- 它通常用于处理网络事件,如接受新连接和处理读写事件。
- 在应用程序启动时,可以创建一个或多个EventLoopGroup实例,其中一个用于处理连接的接受,另外一个用于处理连接的读写操作。
- EventLoopGroup通常有两种类型:单线程的和多线程的。
EventLoop
- EventLoop是Netty的核心组件,它代表了一个不断循环的处理任务。
- 每个EventLoop都与一个线程绑定,并负责处理任务队列中的事件。
- 它在事件循环中不断地从任务队列中获取任务并执行,直到任务队列为空或者遇到需要阻塞的任务。
- EventLoop使用非阻塞方式执行任务,避免了线程切换的开销,提高了性能和吞吐量。
Netty的线程模型采用了Reactor模式,其中EventLoop充当了事件处理器的角色,EventLoopGroup负责管理多个EventLoop,可以根据需要创建单线程或多线程的EventLoopGroup来适应不同的场景。这种设计使得Netty能够高效地处理并发连接和网络事件,提供了高性能的网络编程解决方案。
(四)其他扩展高频知识考点
针对人员:
1.全部人员都适用;
2.正式员工+外包员工:看情况,但基本建议至少考察一道;
1.LRU算法是怎么实现的?大致说明下(高频考点)
LRU算法的设计原则 :如果一个数据在最近一段时间没有被访问到,那么在将来它被访问的可能性也很小。也就是说,当限定的空间已存满数据时,应当把最久没有被访问到的数据淘汰。
当存在热点数据时,LRU的效率很好,但偶发性的、周期性的批量操作会导致LRU命中率急剧下降,缓存污染情况比较严重。
实现LRU思路:
第一种方法:利用数组来实现
- 用一个数组来存储数据,给每一个数据项标记一个访问时间戳
- 每次插入新数据项的时候,先把数组中存在的数据项的时间戳自增,并将新数据项的时间戳置为0并插入到数组中
- 每次访问数组中的数据项的时候,将被访问的数据项的时间戳置为0。
- 当数组空间已满时,将时间戳最大的数据项淘汰。
第二种方法:利用链表来实现
- 每次新插入数据的时候将新数据插到链表的头部
- 每次缓存命中(即数据被访问),则将数据移到链表头部;
- 那么当链表满的时候,就将链表尾部的数据丢弃。
第三种方法:利用链表和hashmap来实现
- 当需要插入新的数据项的时候,如果新数据项在链表中存在(一般称为命中),则把该节点移到链表头部,如果不存在,则新建一个节点,放到链表头部,若缓存满了,则把链表最后一个节点删除即可。
- 在访问数据的时候,如果数据项在链表中存在,则把该节点移到链表头部,否则返回-1。这样一来在链表尾部的节点就是最近最久未访问的数据项。
对于第一种方法,需要不停地维护数据项的访问时间戳,另外,在插入数据、删除数据以及访问数据时,时间复杂度都是O(n)。对于第二种方法,链表在定位数据的时候时间复杂度为O(n)。所以在一般使用第三种方式来是实现LRU算法。
具体实现方案:使用LinkedHashMap实现
LinkedHashMap底层就是用的HashMap加双链表实现的,而且本身已经实现了按照访问顺序的存储。
此外,LinkedHashMap中本身就实现了一个方法removeEldestEntry用于判断是否需要移除最不常读取的数,方法默认是直接返回false,不会移除元素,所以需要重写该方法。即当缓存满后就移除最不常用的数。
java
public class LRU<K,V> {
private static final float hashLoadFactory = 0.75f;
private LinkedHashMap<K,V> map;
private int cacheSize;
public LRU(int cacheSize) {
this.cacheSize = cacheSize;
int capacity = (int)Math.ceil(cacheSize / hashLoadFactory) + 1;
map = new LinkedHashMap<K,V>(capacity, hashLoadFactory, true){
private static final long serialVersionUID = 1;
/*将LinkedHashMap中的removeEldestEntry进行重写改造*/
@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
return size() > LRU.this.cacheSize;
}
};
}
public synchronized V get(K key) {
return map.get(key);
}
public synchronized void put(K key, V value) {
map.put(key, value);
}
public synchronized void clear() {
map.clear();
}
public synchronized int usedSize() {
return map.size();
}
public void print() {
for (Map.Entry<K, V> entry : map.entrySet()) {
System.out.print(entry.getValue() + "--");
}
System.out.println();
}
}
自编代码:基于 HashMap 和 双向链表实现 LRU
基本代码见: LRU缓存机制(LRU Cache)
整体的设计思路是:可以使用 HashMap 存储 key,这样可以做到 put 和 get key的时间都是 O(1),而 HashMap 的 Value 指向双向链表实现的 LRU 的 Node 节点,如图所示。
LRU 存储是基于双向链表实现的,下面的图演示了它的原理。其中 h 代表双向链表的表头,t 代表尾部。首先预先设置 LRU 的容量,如果存储满了,可以通过 O(1) 的时间淘汰掉双向链表的尾部,每次新增和访问数据,都可以通过 O(1)的效率把新的节点增加到对头,或者把已经存在的节点移动到队头。
总结一下核心操作的步骤:
- put(key, value),首先在 HashMap 找到 Key 对应的节点,如果节点存在,更新节点的值,并把这个节点移动队头。如果不存在,需要构造新的节点,并且尝试把节点塞到队头,如果LRU空间不足,则通过 tail 淘汰掉队尾的节点,同时在 HashMap 中移除 Key。
- get(key),通过 HashMap 找到 LRU 链表节点,把节点插入到队头,返回缓存的值。
定义基本结构:
java
class DLinkedNode {
String key;
int value;
DLinkedNode pre;
DLinkedNode post;
}
具体手写代码如下:
java
package org.zyf.javabasic.letcode.hash;
import java.util.HashMap;
import java.util.Map;
/**
* @author yanfengzhang
* @description 设计和实现一个 LRU (最近最少使用) 缓存机制。它应该支持以下操作:获取数据 get 和写入数据 put 。
* 获取数据 get(key) - 如果密钥 (key) 存在于缓存中,则获取密钥的值(总是正数),否则返回 -1。
* 写入数据 put(key, value) - 如果密钥不存在,则写入其数据值。当缓存容量达到上限时,
* 它应该在写入新数据之前删除最近最少使用的数据值,从而为新的数据值留出空间。
* <p>
* 进阶:你是否可以在 O(1) 时间复杂度内完成这两种操作?
* @date 2023/4/9 19:11
*/
public class LRUCache {
class DLinkedNode {
int key;
int value;
DLinkedNode prev;
DLinkedNode next;
}
private Map<Integer, DLinkedNode> cache = new HashMap<>();
private int size;
private int capacity;
private DLinkedNode head, tail;
public LRUCache(int capacity) {
this.size = 0;
this.capacity = capacity;
head = new DLinkedNode();
tail = new DLinkedNode();
head.next = tail;
tail.prev = head;
}
public int get(int key) {
DLinkedNode node = cache.get(key);
if (node == null) {
return -1;
}
/*将节点移动到双向链表头部*/
moveToHead(node);
return node.value;
}
public void put(int key, int value) {
DLinkedNode node = cache.get(key);
if (node == null) {
/*如果节点不存在,则创建一个新节点并加入到双向链表头部和哈希表中*/
DLinkedNode newNode = new DLinkedNode();
newNode.key = key;
newNode.value = value;
cache.put(key, newNode);
addToHead(newNode);
size++;
if (size > capacity) {
/*如果超出容量,则删除双向链表尾部节点并在哈希表中删除对应的键值对*/
DLinkedNode tail = removeTail();
cache.remove(tail.key);
size--;
}
} else {
/*如果节点存在,则更新节点的值,并将节点移动到双向链表头部*/
node.value = value;
moveToHead(node);
}
}
private void addToHead(DLinkedNode node) {
/*将节点加入到双向链表头部*/
node.prev = head;
node.next = head.next;
head.next.prev = node;
head.next = node;
}
private void removeNode(DLinkedNode node) {
/*从双向链表中删除节点*/
node.prev.next = node.next;
node.next.prev = node.prev;
}
private void moveToHead(DLinkedNode node) {
/*将节点移动到双向链表头部*/
removeNode(node);
addToHead(node);
}
private DLinkedNode removeTail() {
/*删除双向链表尾部节点,并返回被删除的节点*/
DLinkedNode tail = this.tail.prev;
removeNode(tail);
return tail;
}
/**
* 可以看到,LRU 缓存机制在存储容量达到最大值时,
* 能够正确地淘汰最近最少使用的节点,
* 并保证每个节点的访问顺序符合 LRU 缓存机制的要求。
*/
public static void main(String[] args) {
LRUCache cache = new LRUCache(2);
cache.put(1, 1);
cache.put(2, 2);
/*output: 1*/
System.out.println(cache.get(1));
cache.put(3, 3);
/*output: -1*/
System.out.println(cache.get(2));
cache.put(4, 4);
/*output: -1*/
System.out.println(cache.get(1));
/*output: 3*/
System.out.println(cache.get(3));
/*output: 4*/
System.out.println(cache.get(4));
}
}
其他相关内容补充:LRU-K
LRU-K中的K代表最近使用的次数,因此LRU可以认为是LRU-1。LRU-K的主要目的是为了解决LRU算法"缓存污染"的问题,其核心思想是将"最近使用过1次"的判断标准扩展为"最近使用过K次"。
相比LRU,LRU-K需要多维护一个队列,用于记录所有缓存数据被访问的历史。只有当数据的访问次数达到K次的时候,才将数据放入缓存。当需要淘汰数据时,LRU-K会淘汰第K次访问时间距当前时间最大的数据。
- 数据第一次被访问时,加入到历史访问列表,如果数据在访问历史列表中没有达到K次访问,则按照一定的规则(FIFO,LRU)淘汰;
- 当访问历史队列中的数据访问次数达到K次后,将数据索引从历史队列中删除,将数据移到缓存队列中,并缓存数据,缓存队列重新按照时间排序;
- 缓存数据队列中被再次访问后,重新排序,需要淘汰数据时,淘汰缓存队列中排在末尾的数据,即"淘汰倒数K次访问离现在最久的数据"。
LRU-K具有LRU的优点,同时还能避免LRU的缺点,实际应用中LRU-2是综合最优的选择。由于LRU-K还需要记录那些被访问过、但还没有放入缓存的对象,因此内存消耗会比LRU要多。
其他相关内容补充:two queue
Two queues(以下使用2Q代替)算法类似于LRU-2,不同点在于2Q将LRU-2算法中的访问历史队列(注意这不是缓存数据的)改为一个FIFO缓存队列,即:2Q算法有两个缓存队列,一个是FIFO队列,一个是LRU队列。
- 当数据第一次访问时,2Q算法将数据缓存在FIFO队列里面,当数据第二次被访问时,则将数据从FIFO队列移到LRU队列里面,两个队列各自按照自己的方法淘汰数据。
- 新访问的数据插入到FIFO队列中,如果数据在FIFO队列中一直没有被再次访问,则最终按照FIFO规则淘汰;
- 如果数据在FIFO队列中再次被访问到,则将数据移到LRU队列头部,如果数据在LRU队列中再次被访问,则将数据移动LRU队列头部,LRU队列淘汰末尾的数据。
其他相关内容补充:Multi Queue(MQ)
MQ算法根据访问频率将数据划分为多个队列,不同的队列具有不同的访问优先级,其核心思想是:优先缓存访问次数多的数据。Q0,Q1....Qk代表不同的优先级队列,Q-history代表从缓存中淘汰数据,但记录了数据的索引和引用次数的队列:
- 新插入的数据放入Q0,每个队列按照LRU进行管理,当数据的访问次数达到一定次数,需要提升优先级时,将数据从当前队列中删除,加入到高一级队列的头部;
- 为了防止高优先级数据永远不会被淘汰,当数据在指定的时间里没有被访问时,需要降低优先级,将数据从当前队列删除,加入到低一级的队列头部;
- 需要淘汰数据时,从最低一级队列开始按照LRU淘汰,每个队列淘汰数据时,将数据从缓存中删除,将数据索引加入Q-history头部。如果数据在Q-history中被重新访问,则重新计算其优先级,移到目标队列头部。Q-history按照LRU淘汰数据的索引。
MQ需要维护多个队列,且需要维护每个数据的访问时间,复杂度比LRU高。
2.后台服务出现明显"变慢",谈谈你的诊断思路?(高频考点)
首先,需要对这个问题进行更加清晰的定义:
- 服务是突然变慢还是长时间运行后观察到变慢?类似问题是否重复出现?
- "慢"的定义是什么,我能够理解是系统对其他方面的请求的反应延时变长吗?
第二,理清问题的症状,这更便于定位具体的原因,有以下一些思路:
- 问题可能来自于 Java 服务自身,也可能仅仅是受系统里其他服务的影响。初始判断可以先确认是否出现了意外的程序错误,例如检查应用本身的错误日志。
对于分布式系统,很多公司都会实现更加系统的日志、性能等监控系统。一些 Java 诊断工具也可以用于这个诊断,例如通过 JFR(Java Flight Recorder),监控应用是否大量出现了某种类型的异常。
如果有,那么异常可能就是个突破点。
如果没有,可以先检查系统级别的资源等情况,监控 CPU、内存等资源是否被其他进程大量占用,并且这种占用是否不符合系统正常运行状况。
- 监控 Java 服务自身,例如 GC 日志里面是否观察到 Full GC 等恶劣情况出现,或者是否 Minor GC 在变长等;利用 jstat 等工具,获取内存使用的统计信息也是个常用手段;利用 jstack 等工具检查是否出现死锁等。
- 如果还不能确定具体问题,对应用进行 Profiling 也是个办法,但因为它会对系统产生侵入性,如果不是非常必要,大多数情况下并不建议在生产系统进行。
- 定位了程序错误或者 JVM 配置的问题后,就可以采取相应的补救措施,然后验证是否解决,否则还需要重复上面部分过程。
3.你了解Java应用开发中的注入攻击吗?
注入式(Inject)攻击是一类非常常见的攻击方式,其基本特征是程序允许攻击者将不可信的动态内容注入到程序中,并将其执行,这就可能完全改变最初预计的执行过程,产生恶意效果。
下面是几种主要的注入式攻击途径,原则上提供动态执行能力的语言特性,都需要提防发生注入攻击的可能。
首先,就是最常见的 SQL 注入攻击。一个典型的场景就是 Web 系统的用户登录功能,根据用户输入的用户名和密码,我们需要去后端数据库核实信息。
假设应用逻辑是,后端程序利用界面输入动态生成类似下面的 SQL,然后让 JDBC 执行。
select * from use_info where username = "input_usr_name" and password = "input_pwd"
但是,如果我输入的 input_pwd 是类似下面的文本,
" or ""="
那么,拼接出的 SQL 字符串就变成了下面的条件,OR 的存在导致输入什么名字都是复合条件的。
select * from use_info where username = "input_usr_name" and password = "" or "" = ""
这里只是举个简单的例子,它是利用了期望输入和可能输入之间的偏差。上面例子中,期望用户输入一个数值,但实际输入的则是 SQL 语句片段。类似场景可以利用注入的不同 SQL 语句,进行各种不同目的的攻击,甚至还可以加上";delete xxx"之类语句,如果数据库权限控制不合理,攻击效果就可能是灾难性的。
第二,操作系统命令注入。Java 语言提供了类似 Runtime.exec(...) 的 API,可以用来执行特定命令,假设我们构建了一个应用,以输入文本作为参数,执行下面的命令:
ls --la input_file_name
但是如果用户输入是 "input_file_name;rm --rf /*",这就有可能出现问题了。当然,这只是个举例,Java 标准类库本身进行了非常多的改进,所以类似这种编程错误,未必可以真的完成攻击,但其反映的一类场景是真实存在的。
第三,XML 注入攻击。Java 核心类库提供了全面的 XML 处理、转换等各种 API,而 XML 自身是可以包含动态内容的,例如 XPATH,如果使用不当,可能导致访问恶意内容。
还有类似 LDAP 等允许动态内容的协议,都是可能利用特定命令,构造注入式攻击的,包括 XSS(Cross-site Scripting)攻击,虽然并不和 Java 直接相关,但也可能在 JSP 等动态页面中发生。
4.在Java程序运行阶段,可以用什么命令行工具来查看当前Java程序的一些启动参数值,例如Heap Size等。
在Java程序运行阶段,可以使用以下命令行工具来查看当前Java程序的一些启动参数值,例如Heap Size等:
-
jps(Java进程状态工具) :
jps
命令用于列出当前系统中所有Java进程的进程ID和主类名。可以通过执行jps -l
命令查看Java程序的启动参数和进程ID。 -
jcmd(Java控制台命令) :
jcmd
命令用于向正在运行的Java进程发送诊断命令,可以用来查看Java进程的启动参数、内存使用情况等信息。例如,可以执行jcmd <pid> VM.flags
命令来查看Java进程的VM flags。 -
jstat(Java统计监视工具) :
jstat
命令用于监视Java虚拟机的各种运行时信息,包括垃圾回收情况、类加载情况、JIT编译情况等。可以使用jstat -gc <pid>
命令来查看Java进程的垃圾回收情况。 -
jmap(Java内存映像工具) :
jmap
命令用于生成Java进程的内存映像文件,可以查看Java进程的堆内存使用情况、内存分布情况等。可以执行jmap -heap <pid>
命令来查看Java进程的堆内存情况。 -
jconsole(Java监视与管理控制台) :
jconsole
是Java自带的图形化监视与管理控制台工具,可以实时监视Java应用程序的内存使用情况、线程情况、类加载情况等,非常方便实用。
这些命令行工具提供了丰富的功能,可以帮助开发人员深入了解Java程序的运行状态和性能特征,从而进行调优和排查问题。
5.用什么命令行工具可以查看运行的Java程序的GC状况,请具体写出命令行格式。(高频考点)
可以使用 jstat
命令查看运行的Java程序的GC(垃圾回收)状况。具体的命令行格式如下:
java
jstat -gc <pid> <interval> <count>
其中,各个参数的含义如下:
<pid>
:Java进程的进程ID,即要监视的Java程序的进程ID。<interval>
:监视数据输出的时间间隔,单位为毫秒。表示每隔多少毫秒输出一次监视数据。<count>
:输出监视数据的次数。表示输出多少次监视数据后停止监视。
例如,要查看进程ID为12345的Java程序的GC状况,每隔1秒输出一次监视数据,输出5次,可以执行以下命令:
java
jstat -gc 12345 1000 5
这样会输出指定Java进程的GC相关的监视数据,包括GC时间、各代的GC统计信息(如Eden区、Survivor区、Old区等)。
6.用什么工具,可以在Java程序运行的情况下跟踪某个方法的执行时间,请求参数信息等,并请解释下工具实现的原理。
在Java程序运行的情况下,可以使用以下工具来跟踪某个方法的执行时间、请求参数信息等:
-
Java Profiler(Java性能分析器):Java性能分析器是一种用于监视和诊断Java应用程序性能问题的工具,常见的Java性能分析器包括VisualVM、YourKit Java Profiler、JProfiler等。这些工具提供了方法级别的性能分析功能,可以跟踪方法的执行时间、调用堆栈、请求参数信息等。
-
AspectJ(面向切面编程框架):AspectJ是一种基于Java语言的面向切面编程框架,它可以在方法执行前后插入代码逻辑,实现对方法的增强和跟踪。通过在目标方法的前后插入代码逻辑,可以实现对方法执行时间、请求参数信息等的跟踪和监视。
-
自定义拦截器/过滤器:在Java Web应用中,可以通过自定义拦截器或过滤器来实现对方法的执行时间、请求参数信息等的跟踪。拦截器或过滤器可以在方法执行前后记录方法的执行时间、请求参数信息等,并将这些信息记录到日志或输出到控制台。
这些工具和技术的实现原理主要包括以下几个方面:
-
字节码注入:Java性能分析器通常通过在Java程序的字节码中插入监视代码来实现性能监视和跟踪。AspectJ框架通过在编译期或运行期修改字节码来实现对方法的增强和跟踪。
-
AOP(面向切面编程):AspectJ框架采用面向切面编程的思想,通过定义切点和切面来实现对方法的跟踪和监视。切点定义了哪些方法需要被跟踪,切面定义了在方法执行前后需要执行的增强逻辑。
-
拦截器/过滤器链:自定义拦截器或过滤器通常通过拦截器链或过滤器链来实现对方法的跟踪和监视。在方法执行前后,拦截器或过滤器会执行相应的逻辑,记录方法的执行时间、请求参数信息等,并将这些信息输出到日志或控制台。
这些工具和技术提供了丰富的功能和灵活的扩展性,可以帮助开发人员实现对Java程序的性能监视和跟踪,从而及时发现和解决性能问题。
7.当一个Java程序接收请求,很长时间都没响应的话,通常你会怎么去排查这种问题?
当一个Java程序接收请求后很长时间都没有响应,通常会采取以下步骤来排查这种问题:
-
确认是否出现了死锁或长时间阻塞:首先,需要确认是否出现了死锁或者某些线程长时间阻塞的情况。可以使用线程监视工具(如jstack、VisualVM等)来查看Java进程的线程堆栈信息,以确定是否有线程被阻塞或者等待锁资源。
-
检查日志文件:查看程序的日志文件,查找异常信息、错误日志或者警告信息,以确定是否有异常情况发生。有时候程序的运行状态会被记录在日志文件中,通过查看日志可以发现一些隐藏的问题。
-
检查性能指标:使用性能监视工具(如VisualVM、JProfiler、Grafana等)来监视Java程序的性能指标,包括CPU利用率、内存使用情况、线程数量、GC情况等。通过检查性能指标,可以发现程序的瓶颈和性能问题。
-
查看系统资源情况:检查服务器的系统资源情况,包括CPU使用率、内存使用情况、磁盘IO等。有时候程序没有响应是因为服务器资源不足或者被其他程序占用了过多的资源。
-
分析代码逻辑:检查程序的代码逻辑,尤其是处理请求的关键路径,查看是否有死循环、长时间阻塞、数据库连接池耗尽等问题。有时候程序没有响应是因为代码中存在性能问题或者业务逻辑错误。
通过以上步骤的排查和分析,通常可以找到程序没有响应的原因,并采取相应的措施进行解决。
8.NIO(New I/O)用到的组件有哪些?
NIO(New I/O)是 Java 中的一组非阻塞 I/O 类库,引入了更为灵活和高效的 I/O 操作方式。在 NIO 中,有一些重要的组件和概念,以下是一些常见的 NIO 组件:
-
通道(Channels): 通道是连接到文件、套接字或其他可进行 I/O 操作的实体。它们类似于传统的流,但提供了更多的功能。通道可以用于读取、写入和操作数据。
-
缓冲区(Buffers): 缓冲区是一个内存区域,用于在通道和应用程序之间传输数据。NIO 缓冲区提供了不同类型的缓冲区(如 ByteBuffer、CharBuffer、IntBuffer 等),以适应不同类型的数据。
-
选择器(Selector): 选择器允许单个线程同时监视多个通道的 I/O 事件。使用选择器,可以实现非阻塞的多路复用 I/O 操作,以管理多个连接。
-
选择键(SelectionKey): 选择键是通道在选择器上注册的标记。它包含了通道的事件和状态信息,允许选择器跟踪通道的状态。
-
非阻塞 I/O: NIO 提供了非阻塞 I/O 操作,允许在数据没有准备好的情况下继续执行其他任务,而不是阻塞等待数据的到来。这在高并发环境中非常有用。
-
多路复用: NIO 的选择器允许一个线程同时处理多个通道的事件,从而实现多路复用。这在服务器端应用程序中非常有用,可以处理多个客户端连接。
-
通道间的数据传输: NIO 提供了直接通道间的数据传输方法,可以在通道之间高效地传输数据,避免了通过缓冲区中转的开销。
这些组件一起构成了 NIO 的核心架构,使得 Java 程序能够更高效地进行非阻塞 I/O 操作,适用于需要高并发处理的网络应用程序。
9.Netty对比Java NIO做了什么优化?(必考)
Netty 是一个基于 Java NIO 的高性能网络应用框架,它在 Java NIO 的基础上做了一些优化和扩展,以提供更强大、更易用的网络编程能力。
下面是 Netty 相对于 Java NIO 做出的一些优化:
- 简化了编程模型:Netty 提供了更简单、更易用的编程模型,隐藏了 Java NIO 复杂的细节。它的事件驱动模型和回调机制使得编写高效、可扩展的网络应用更加简单。
- 提供了更高级的抽象 :Netty 提供了一系列高级的抽象组件,如 Channel、ChannelHandler 和 ChannelPipeline,它们使得网络应用的开发更加模块化和可扩展。同时,Netty 还提供了丰富的编解码器,简化了网络数据的编解码过程。
- 内存管理优化 :Netty 在内存管理上进行了优化,引入了零拷贝技术和内存池,减少了数据在内存之间的复制操作,提高了数据的传输效率。
- 提供了更强大的并发模型 :Netty 支持多种并发模型,包括单线程模型、多线程模型和多线程池模型。它提供了基于事件驱动的方式进行网络处理,避免了传统的阻塞式 I/O 的性能瓶颈。
- 支持更多的协议和特性 :Netty 提供了丰富的协议实现,包括 TCP、UDP、HTTP、WebSocket 等,同时还支持 SSL/TLS 加密和压缩、流量整形和拆包粘包处理等特性。
总体而言,Netty 在基于 Java NIO 的基础上进行了一系列的优化和扩展,使得开发者能够更轻松地构建高性能、可扩展的网络应用。它提供了简化的编程模型、高级抽象、内存管理优化、强大的并发模型和支持多种协议和特性,使得网络编程变得更加灵活、高效和可靠。