java面试笔记八股文(二)

java面试笔记八股文(二)
李阳一、java基础篇
1. 怎么理解面向对象?简单说说封装继承多态
面向对象是一种编程范式,它将现实世界中的事物抽象为对象,对象具有属性(称为字段或属性)和行为(称为方法)。面向对象编程的设计思想是以对象为中心,通过对象之间的交互来完成程序的功能,具有灵活性和可扩展性,通过封装和继承可以更好地应对需求变化。
Java面向对象的三大特性包括:封装、继承、多态:
- 封装:封装是指将对象的属性(数据)和行为(方法)结合在一起,对外隐藏对象的内部细节,仅通过对象提供的接口与外界交互。封装的目的是增强安全性和简化编程,使得对象更加独立。
- 继承:继承是一种可以使得子类自动共享父类数据结构和方法的机制。它是代码复用的重要手段,通过继承可以建立类与类之间的层次关系,使得结构更加清晰。
- 多态:多态是指允许不同类的对象对同一消息作出响应。即同一个接口,使用不同的实例而执行不同操作。多态性可以分为编译时多态(重载)和运行时多态(重写)。它使得程序具有良好的灵活性和扩展性
2. 多态体现在哪几个方面?
多态在面向对象编程中可以体现在以下几个方面:
- 方法重载:
- 方法重载是指同一类中可以有多个同名方法,它们具有不同的参数列表(参数类型、数量或顺序不同)。虽然方法名相同,但根据传入的参数不同,编译器会在编译时确定调用哪个方法。
- 示例:对于一个
add方法,可以定义为add(int a, int b)和add(double a, double b)。
- 方法重写:
- 方法重写是指子类能够提供对父类中同名方法的具体实现。在运行时,JVM会根据对象的实际类型确定调用哪个版本的方法。这是实现多态的主要方式。
- 示例:在一个动物类中,定义一个
sound方法,子类Dog可以重写该方法以实现bark,而Cat可以实现meow。
- 接口与实现:
- 多态也体现在接口的使用上,多个类可以实现同一个接口,并且用接口类型的引用来调用这些类的方法。这使得程序在面对不同具体实现时保持一贯的调用方式。
- 示例:多个类(如
Dog,Cat)都实现了一个Animal接口,当用Animal类型的引用来调用makeSound方法时,会触发对应的实现。
- 向上转型和向下转型:
- 在Java中,可以使用父类类型的引用指向子类对象,这是向上转型。通过这种方式,可以在运行时期采用不同的子类实现。
- 向下转型是将父类引用转回其子类类型,但在执行前需要确认引用实际指向的对象类型以避免
ClassCastException
3. 多态解决了什么问题?
多态是指子类可以替换父类,在实际的代码运行过程中,调用子类的方法实现。多态这种特性也需要编程语言提供特殊的语法机制来实现,比如继承、接口类。
多态可以提高代码的扩展性和复用性,是很多设计模式、设计原则、编程技巧的代码实现基础。比如策略模式、基于接口而非实现编程、依赖倒置原则、里式替换原则、利用多态去掉冗长的 if-else 语句等等
4. 重载与重写有什么区别?
- 重载(Overloading)指的是在同一个类中,可以有多个同名方法,它们具有不同的参数列表(参数类型、参数个数或参数顺序不同),编译器根据调用时的参数类型来决定调用哪个方法。
- 重写(Overriding)指的是子类可以重新定义父类中的方法,方法名、参数列表和返回类型必须与父类中的方法一致,通过@override注解来明确表示这是对父类方法的重写。
重载是指在同一个类中定义多个同名方法,而重写是指子类重新定义父类中的方法。
5. 抽象类和普通类区别?
- 实例化:普通类可以直接实例化对象,而抽象类不能被实例化,只能被继承。
- 方法实现:普通类中的方法可以有具体的实现,而抽象类中的方法可以有实现也可以没有实现。
- 继承:一个类可以继承一个普通类,而且可以继承多个接口;而一个类只能继承一个抽象类,但可以同时实现多个接口。
- 实现限制:普通类可以被其他类继承和使用,而抽象类一般用于作为基类,被其他类继承和扩展使用。
6. Java抽象类和接口的区别是什么?
两者的特点:
- 抽象类用于描述类的共同特性和行为,可以有成员变量、构造方法和具体方法。适用于有明显继承关系的场景。
- 接口用于定义行为规范,可以多实现,只能有常量和抽象方法(Java 8 以后可以有默认方法和静态方法)。适用于定义类的能力或功能。
两者的区别:
- 实现方式:实现接口的关键字为implements,继承抽象类的关键字为extends。一个类可以实现多个接口,但一个类只能继承一个抽象类。所以,使用接口可以间接地实现多重继承。
- 方法方式:接口只有定义,不能有方法的实现,java 1.8中可以定义default方法体,而抽象类可以有定义与实现,方法可在抽象类中实现。
- 访问修饰符:接口成员变量默认为public static final,必须赋初值,不能被修改;其所有的成员方法都是public、abstract的。抽象类中成员变量默认default,可在子类中被重新定义,也可被重新赋值;抽象方法被abstract修饰,不能被private、static、synchronized和native等修饰,必须以分号结尾,不带花括号。
- 变量:抽象类可以包含实例变量和静态变量,而接口只能包含常量(即静态常量)。
6. Java 中 final 作用是什么?
final关键字主要有以下三个方面的作用:用于修饰类、方法和变量。
- 修饰类:当
final修饰一个类时,表示这个类不能被继承,是类继承体系中的最终形态。 - 修饰方法:用
final修饰的方法不能在子类中被重写。 - 修饰变量:当
final修饰基本数据类型的变量时,该变量一旦被赋值就不能再改变。
7. 深拷贝和浅拷贝的区别?
- 浅拷贝是指只复制对象本身和其内部的值类型字段,但不会复制对象内部的引用类型字段。换句话说,浅拷贝只是创建一个新的对象,然后将原对象的字段值复制到新对象中,但如果原对象内部有引用类型的字段,只是将引用复制到新对象中,两个对象指向的是同一个引用对象。
- 深拷贝是指在复制对象的同时,将对象内部的所有引用类型字段的内容也复制一份,而不是共享引用。换句话说,深拷贝会递归复制对象内部所有引用类型的字段,生成一个全新的对象以及其内部的所有对象。
8. 什么是反射?
Java 反射机制是在运行状态中,对于任意一个类,都能够知道这个类中的所有属性和方法,对于任意一个对象,都能够调用它的任意一个方法和属性;这种动态获取的信息以及动态调用对象的方法的功能称为 Java 语言的反射机制。
反射具有以下特性:
- 运行时类信息访问:反射机制允许程序在运行时获取类的完整结构信息,包括类名、包名、父类、实现的接口、构造函数、方法和字段等。
- 动态对象创建:可以使用反射API动态地创建对象实例,即使在编译时不知道具体的类名。这是通过Class类的newInstance()方法或Constructor对象的newInstance()方法实现的。
- 动态方法调用:可以在运行时动态地调用对象的方法,包括私有方法。这通过Method类的invoke()方法实现,允许你传入对象实例和参数值来执行方法。
- 访问和修改字段值:反射还允许程序在运行时访问和修改对象的字段值,即使是私有的。这是通过Field类的get()和set()方法完成的。
9. 反射在你平时写代码或者框架中的应用场景有哪些?
加载数据库驱动
我们的项目底层数据库有时是用mysql,有时用oracle,需要动态地根据实际情况加载驱动类,这个时候反射就有用了,假设 com.mikechen.java.myqlConnection,com.mikechen.java.oracleConnection这两个类我们要用。
配置文件加载
Spring 框架的 IOC(动态加载管理 Bean),Spring通过配置文件配置各种各样的bean,你需要用到哪些bean就配哪些,spring容器就会根据你的需求去动态加载,你的程序就能健壮地运行。
10. Java注解的作用域呢?
注解的作用域(Scope)指的是注解可以应用在哪些程序元素上,例如类、方法、字段等。Java注解的作用域可以分为三种:
- 类级别作用域:用于描述类的注解,通常放置在类定义的上面,可以用来指定类的一些属性,如类的访问级别、继承关系、注释等。
- 方法级别作用域:用于描述方法的注解,通常放置在方法定义的上面,可以用来指定方法的一些属性,如方法的访问级别、返回值类型、异常类型、注释等。
- 字段级别作用域:用于描述字段的注解,通常放置在字段定义的上面,可以用来指定字段的一些属性,如字段的访问级别、默认值、注释等。
除了这三种作用域,Java还提供了其他一些注解作用域,例如构造函数作用域和局部变量作用域。这些注解作用域可以用来对构造函数和局部变量进行描述和注释。
11. == 与 equals 有什么区别?
对于字符串变量来说,使用”==”和”equals”比较字符串时,其比较方法不同。”==”比较两个变量本身的值,即两个对象在内存中的首地址,”equals”比较字符串包含内容是否相同。
对于非字符串变量来说,如果没有对equals()进行重写的话,”==” 和 “equals”方法的作用是相同的,都是用来比较对象在堆内存中的首地址,即用来比较两个引用变量是否指向同一个对象。
- ==:比较的是两个字符串内存地址(堆内存)的数值是否相等,属于数值比较;
- equals():比较的是两个字符串的内容,属于内容比较。
12. hashcode和equals方法有什么关系?
在 Java 中,对于重写 equals 方法的类,通常也需要重写 hashCode 方法,并且需要遵循以下规定:
- 一致性:如果两个对象使用
equals方法比较结果为true,那么它们的hashCode值必须相同。也就是说,如果obj1.equals(obj2)返回true,那么obj1.hashCode()必须等于obj2.hashCode()。 - 非一致性:如果两个对象的
hashCode值相同,它们使用equals方法比较的结果不一定为true。即obj1.hashCode() == obj2.hashCode()时,obj1.equals(obj2)可能为false,这种情况称为哈希冲突。
hashCode 和 equals 方法是紧密相关的,重写 equals 方法时必须重写 hashCode 方法,以保证在使用哈希表等数据结构时,对象的相等性判断和存储查找操作能够正常工作。而重写 hashCode 方法时,需要确保相等的对象具有相同的哈希码,但相同哈希码的对象不一定相等
13. String、StringBuffer、StringBuilder的区别和联系
1、可变性 :String 是不可变的(Immutable),一旦创建,内容无法修改,每次修改都会生成一个新的对象。StringBuilder 和 StringBuffer 是可变的(Mutable),可以直接对字符串内容进行修改而不会创建新对象。
2、线程安全性 :String 因为不可变,天然线程安全。StringBuilder 不是线程安全的,适用于单线程环境。StringBuffer 是线程安全的,其方法通过 synchronized 关键字实现同步,适用于多线程环境。
3、性能 :String 性能最低,尤其是在频繁修改字符串时会生成大量临时对象,增加内存开销和垃圾回收压力。StringBuilder 性能最高,因为它没有线程安全的开销,适合单线程下的字符串操作。StringBuffer 性能略低于 StringBuilder,因为它的线程安全机制引入了同步开销。
4、使用场景 :如果字符串内容固定或不常变化,优先使用 String。如果需要频繁修改字符串且在单线程环境下,使用 StringBuilder。如果需要频繁修改字符串且在多线程环境下,使用 StringBuffer。
13。 Java中stream的API介绍一下
Java 8引入了Stream API,它提供了一种高效且易于使用的数据处理方式,特别适合集合对象的操作,如过滤、映射、排序等。Stream API不仅可以提高代码的可读性和简洁性,还能利用多核处理器的优势进行并行处理。
14. 序列化和反序列化让你自己实现你会怎么做?
Java 默认的序列化虽然实现方便,但却存在安全漏洞、不跨语言以及性能差等缺陷。
我会考虑用主流序列化框架,比如FastJson、Protobuf来替代Java 序列化。如果追求性能的话,Protobuf 序列化框架会比较合适,Protobuf 的这种数据存储格式,不仅压缩存储数据的效果好, 在编码和解码的性能方面也很高效
15. Java怎么实现网络IO高并发编程?
可以用 Java NIO ,是一种同步非阻塞的I/O模型,也是I/O多路复用的基础。
NIO 是基于I/O多路复用实现的,它可以只用一个线程处理多个客户端I/O,如果你需要同时管理成千上万的连接,但是每个连接只发送少量数据,例如一个聊天服务器,用NIO实现会更好一些。
二、集合篇
1. 数组与集合区别,用过哪些?
数组和集合的区别:
- 数组是固定长度的数据结构,一旦创建长度就无法改变,而集合是动态长度的数据结构,可以根据需要动态增加或减少元素。
- 数组可以包含基本数据类型和对象,而集合只能包含对象。
- 数组可以直接访问元素,而集合需要通过迭代器或其他方法访问元素。
2. Java中的线程安全的集合是什么?
在 java.util 包中的线程安全的类主要 2 个,其他都是非线程安全的。
- Vector:线程安全的动态数组,其内部方法基本都经过synchronized修饰,如果不需要线程安全,并不建议选择,毕竟同步是有额外开销的。Vector 内部是使用对象数组来保存数据,可以根据需要自动的增加容量,当数组已满时,会创建新的数组,并拷贝原有数组数据。
- Hashtable:线程安全的哈希表,HashTable 的加锁方法是给每个方法加上 synchronized 关键字,这样锁住的是整个 Table 对象,不支持 null 键和值,由于同步导致的性能开销,所以已经很少被推荐使用,如果要保证线程安全的哈希表,可以用ConcurrentHashMap。
3. Collections和Collection的区别
- Collection是Java集合框架中的一个接口,它是所有集合类的基础接口。它定义了一组通用的操作和方法,如添加、删除、遍历等,用于操作和管理一组对象。Collection接口有许多实现类,如List、Set和Queue等。
- Collections(注意有一个s)是Java提供的一个工具类,位于java.util包中。它提供了一系列静态方法,用于对集合进行操作和算法。Collections类中的方法包括排序、查找、替换、反转、随机化等等。这些方法可以对实现了Collection接口的集合进行操作,如List和Set
4. Arraylist和LinkedList的区别,哪个集合是线程安全的?
ArrayList和LinkedList都是Java中常见的集合类,它们都实现了List接口。
- 底层数据结构不同:ArrayList使用数组实现,通过索引进行快速访问元素。LinkedList使用链表实现,通过节点之间的指针进行元素的访问和操作。
- 插入和删除操作的效率不同:ArrayList在尾部的插入和删除操作效率较高,但在中间或开头的插入和删除操作效率较低,需要移动元素。LinkedList在任意位置的插入和删除操作效率都比较高,因为只需要调整节点之间的指针,但是LinkedList是不支持随机访问的,所以除了头结点外插入和删除的时间复杂度都是0(n),效率也不是很高所以LinkedList基本没人用。
- 随机访问的效率不同:ArrayList支持通过索引进行快速随机访问,时间复杂度为O(1)。LinkedList需要从头或尾开始遍历链表,时间复杂度为O(n)。
- 空间占用:ArrayList在创建时需要分配一段连续的内存空间,因此会占用较大的空间。LinkedList每个节点只需要存储元素和指针,因此相对较小。
- 使用场景:ArrayList适用于频繁随机访问和尾部的插入删除操作,而LinkedList适用于频繁的中间插入删除操作和不需要随机访问的场景。
- 线程安全:这两个集合都不是线程安全的,Vector是线程安全的
5. 为什么ArrayList不是线程安全的
在高并发添加数据下,ArrayList会暴露三个问题;
部分值为null(我们并没有add null进去)
当线程1走到了扩容那里发现当前size是9,而数组容量是10,所以不用扩容,这时候cpu让出执行权,线程2也进来了,发现size是9,而数组容量是10,所以不用扩容,这时候线程1继续执行,将数组下标索引为9的位置set值了,还没有来得及执行size++,这时候线程2也来执行了,又把数组下标索引为9的位置set了一遍,这时候两个先后进行size++,导致下标索引10的地方就为null了。
索引越界异常
线程1走到扩容那里发现当前size是9,数组容量是10不用扩容,cpu让出执行权,线程2也发现不用扩容,这时候数组的容量就是10,而线程1 set完之后size++,这时候线程2再进来size就是10,数组的大小只有10,而你要设置下标索引为10的就会越界(数组的下标索引从0开始);
size与我们add的数量不符
这个基本上每次都会发生,这个理解起来也很简单,因为size++本身就不是原子操作,可以分为三步:获取size的值,将size的值加1,将新的size值覆盖掉原来的,线程1和线程2拿到一样的size值加完了同时覆盖,就会导致一次没有加上,所以肯定不会与我们add的数量保持一致的;
6. ArrayList 和 LinkedList 的应用场景?
- ArrayList适用于需要频繁访问集合元素的场景。它基于数组实现,可以通过索引快速访问元素,因此在按索引查找、遍历和随机访问元素的操作上具有较高的性能。当需要频繁访问和遍历集合元素,并且集合大小不经常改变时,推荐使用ArrayList
- LinkedList适用于频繁进行插入和删除操作的场景。它基于链表实现,插入和删除元素的操作只需要调整节点的指针,因此在插入和删除操作上具有较高的性能。当需要频繁进行插入和删除操作,或者集合大小经常改变时,可以考虑使用LinkedList。
7. ArrayList的扩容机制说一下
ArrayList在添加元素时,如果当前元素个数已经达到了内部数组的容量上限,就会触发扩容操作。ArrayList的扩容操作主要包括以下几个步骤:
- 计算新的容量:一般情况下,新的容量会扩大为原容量的1.5倍(在JDK 10之后,扩容策略做了调整),然后检查是否超过了最大容量限制。
- 创建新的数组:根据计算得到的新容量,创建一个新的更大的数组。
- 将元素复制:将原来数组中的元素逐个复制到新数组中。
- 更新引用:将ArrayList内部指向原数组的引用指向新数组。
- 完成扩容:扩容完成后,可以继续添加新元素。
ArrayList的扩容操作涉及到数组的复制和内存的重新分配,所以在频繁添加大量元素时,扩容操作可能会影响性能。为了减少扩容带来的性能损耗,可以在初始化ArrayList时预分配足够大的容量,避免频繁触发扩容操作。
之所以扩容是 1.5 倍,是因为 1.5 可以充分利用移位操作,减少浮点数或者运算时间和运算次数。
6. HashMap是线程安全的吗?
hashmap不是线程安全的,hashmap在多线程会存在下面的问题:
- JDK 1.7 HashMap 采用数组 + 链表的数据结构,多线程背景下,在数组扩容的时候,存在 Entry 链死循环和数据丢失问题。
- JDK 1.8 HashMap 采用数组 + 链表 + 红黑二叉树的数据结构,优化了 1.7 中数组扩容的方案,解决了 Entry 链死循环和数据丢失问题。但是多线程背景下,put 方法存在数据覆盖的问题。
如果要保证线程安全,可以通过这些方法来保证:
- 多线程环境可以使用Collections.synchronizedMap同步加锁的方式,还可以使用HashTable,但是同步的方式显然性能不达标,而ConurrentHashMap更适合高并发场景使用。
- ConcurrentHashmap在JDK1.7和1.8的版本改动比较大,1.7使用Segment+HashEntry分段锁的方式实现,1.8则抛弃了Segment,改为使用CAS+synchronized+Node实现,同样也加入了红黑树,避免链表过长导致性能的问题
7. 为什么HashMap要用红黑树而不是平衡二叉树?
- 平衡二叉树追求的是一种 “完全平衡” 状态:任何结点的左右子树的高度差不会超过 1,优势是树的结点是很平均分配的。这个要求实在是太严了,导致每次进行插入/删除节点的时候,几乎都会破坏平衡树的第二个规则,进而我们都需要通过左旋和右旋来进行调整,使之再次成为一颗符合要求的平衡树。
- 红黑树不追求这种完全平衡状态,而是追求一种 “弱平衡” 状态:整个树最长路径不会超过最短路径的 2 倍。优势是虽然牺牲了一部分查找的性能效率,但是能够换取一部分维持树平衡状态的成本。与平衡树不同的是,红黑树在插入、删除等操作,不会像平衡树那样,频繁着破坏红黑树的规则,所以不需要频繁着调整,这也是我们为什么大多数情况下使用红黑树的原因
6. 已经用了synchronized,为什么还要用CAS呢?
ConcurrentHashMap使用这两种手段来保证线程安全主要是一种权衡的考虑,在某些操作中使用synchronized,还是使用CAS,主要是根据锁竞争程度来判断的。
比如:在putVal中,如果计算出来的hash槽没有存放元素,那么就可以直接使用CAS来进行设置值,这是因为在设置元素的时候,因为hash值经过了各种扰动后,造成hash碰撞的几率较低,那么我们可以预测使用较少的自旋来完成具体的hash落槽操作。
当发生了hash碰撞的时候说明容量不够用了或者已经有大量线程访问了,因此这时候使用synchronized来处理hash碰撞比CAS效率要高,因为发生了hash碰撞大概率来说是线程竞争比较强烈。
7. Set集合有什么特点?如何实现key无重复的?
- set集合特点:Set集合中的元素是唯一的,不会出现重复的元素。
- set实现原理:Set集合通过内部的数据结构(如哈希表、红黑树等)来实现key的无重复。当向Set集合中插入元素时,会先根据元素的hashCode值来确定元素的存储位置,然后再通过equals方法来判断是否已经存在相同的元素,如果存在则不会再次插入,保证了元素的唯一性
8. 说一下HashMap和Hashtable、ConcurrentMap的区别
- HashMap线程不安全,效率高一点,可以存储null的key和value,null的key只能有一个,null的value可以有多个。默认初始容量为16,每次扩充变为原来2倍。创建时如果给定了初始容量,则扩充为2的幂次方大小。底层数据结构为数组+链表,插入元素后如果链表长度大于阈值(默认为8),先判断数组长度是否小于64,如果小于,则扩充数组,反之将链表转化为红黑树,以减少搜索时间。
- HashTable线程安全,效率低一点,其内部方法基本都经过synchronized修饰,不可以有null的key和value。默认初始容量为11,每次扩容变为原来的2n+1。创建时给定了初始容量,会直接用给定的大小。底层数据结构为数组+链表。它基本被淘汰了,要保证线程安全可以用ConcurrentHashMap。
- ConcurrentHashMap是Java中的一个线程安全的哈希表实现,它可以在多线程环境下并发地进行读写操作,而不需要像传统的HashTable那样在读写时加锁。ConcurrentHashMap的实现原理主要基于分段锁和CAS操作。它将整个哈希表分成了多Segment(段),每个Segment都类似于一个小的HashMap,它拥有自己的数组和一个独立的锁。在ConcurrentHashMap中,读操作不需要锁,可以直接对Segment进行读取,而写操作则只需要锁定对应的Segment,而不是整个哈希表,这样可以大大提高并发性能
9. 为什么为什么重写equals必须重写hashcode方法?
hashCode 和 equals 两个方法是用来协同判断两个对象是否相等的,采用这种方式的原因是可以提高程序插入和查询的速度,如果在重写 equals 时,不重写 hashCode,就会导致在某些场景下,例如将两个相等的自定义对象存储在 Set 集合时,就会出现程序执行的异常,为了保证程序的正常执行,所以我们就需要在重写 equals 时,也一并重写 hashCode 方法才行。
三、 并发
1. 使用多线程要注意哪些问题?
要保证多线程的程序是安全,不要出现数据竞争造成的数据混乱的问题。
Java的线程安全在三个方面体现:
- 原子性:提供互斥访问,同一时刻只能有一个线程对数据进行操作,在Java中使用了atomic包(这个包提供了一些支持原子操作的类,这些类可以在多线程环境下保证操作的原子性)和synchronized关键字来确保原子性;
- 可见性:一个线程对主内存的修改可以及时地被其他线程看到,在Java中使用了synchronized和volatile这两个关键字确保可见性;
- 有序性:一个线程观察其他线程中的指令执行顺序,由于指令重排序,该观察结果一般杂乱无序,在Java中使用了happens-before原则来确保有序性
2. 保证数据的一致性有哪些方案呢?
- 事务管理:使用数据库事务来确保一组数据库操作要么全部成功提交,要么全部失败回滚。通过ACID(原子性、一致性、隔离性、持久性)属性,数据库事务可以保证数据的一致性。
- 锁机制:使用锁来实现对共享资源的互斥访问。在 Java 中,可以使用 synchronized 关键字、ReentrantLock 或其他锁机制来控制并发访问,从而避免并发操作导致数据不一致。
- 版本控制:通过乐观锁的方式,在更新数据时记录数据的版本信息,从而避免同时对同一数据进行修改,进而保证数据的一致性。
3. 线程的创建方式有哪些?
继承Thread类
实现Runnable接口
实现Callable接口与FutureTask
4. sleep 和 wait的区别是什么?
- 所属分类的不同:sleep 是
Thread类的静态方法,可以在任何地方直接通过Thread.sleep()调用,无需依赖对象实例。wait 是Object类的实例方法,这意味着必须通过对象实例来调用。 - 锁释放的情况:
Thread.sleep()在调用时,线程会暂停执行指定的时间,但不会释放持有的对象锁。也就是说,在sleep期间,其他线程无法获得该线程持有的锁。Object.wait():调用该方法时,线程会释放持有的对象锁,进入等待状态,直到其他线程调用相同对象的notify()或notifyAll()方法唤醒它 - 使用条件:sleep 可在任意位置调用,无需事先获取锁。 wait 必须在同步块或同步方法内调用(即线程需持有该对象的锁),否则抛出
IllegalMonitorStateException。 - 唤醒机制:sleep 休眠时间结束后,线程 自动恢复 到就绪状态,等待CPU调度。wait 需要其他线程调用相同对象的
notify()或notifyAll()方法才能被唤醒。notify()会随机唤醒一个在该对象上等待的线程,而notifyAll()会唤醒所有在该对象上等待的线程。
5. blocked和waiting有啥区别
- BLOCKED是锁竞争失败后被被动触发的状态,WAITING是人为的主动触发的状态
- BLCKED的唤醒时自动触发的,而WAITING状态是必须要通过特定的方法来主动唤醒
6. wait 状态下的线程如何进行恢复到 running 状态?
线程从 等待(WAIT) 状态恢复到 运行(RUNNING) 状态的核心机制是 通过外部事件触发或资源可用性变化,比如等待的线程被其他线程对象唤醒,notify()和notifyAll()。
7. notify 和 notifyAll 的区别
同样是唤醒等待的线程,同样最多只有一个线程能获得锁,同样不能控制哪个线程获得锁。
区别在于:
- notify:唤醒一个线程,其他线程依然处于wait的等待唤醒状态,如果被唤醒的线程结束时没调用notify,其他线程就永远没人去唤醒,只能等待超时,或者被中断
- notifyAll:所有线程退出wait的状态,开始竞争锁,但只有一个线程能抢到,这个线程执行完后,其他线程又会有一个幸运儿脱颖而出得到锁
8. 线程间通信方式有哪些?
在 Java 中,线程间通信主要是为了解决线程间的协同与数据同步问题,常见的实现方式有以下几种,我结合实际使用场景给您梳理下:
首先是最基础的 Object 类的 wait ()、notify () 和 notifyAll () —— 这组方法必须配合 synchronized 锁使用,依赖对象的监视器机制。比如当一个线程执行 wait () 时,会释放当前对象锁并进入等待队列;其他线程执行 notify ()/notifyAll () 时,会唤醒队列中的线程,让它们重新竞争锁。典型场景比如 “生产者 - 消费者” 模型,生产者生产完数据后 notify () 唤醒消费者,消费者消费完再 wait () 等待下一次通知。
其次是 Lock 接口与 Condition 接口 —— 这是 JUC 包提供的更灵活的方案,比 synchronized 功能更强。Lock 负责加锁解锁,Condition 则对应 “等待 - 通知” 逻辑,而且一个 Lock 可以关联多个 Condition,实现更精细的线程分组唤醒。比如用 ReentrantLock 配合两个 Condition,分别管理生产者和消费者线程,避免像 notifyAll () 那样唤醒无关线程,减少资源浪费。
然后是 volatile 关键字 —— 它主要通过保证变量的 “可见性” 实现简单通信。当一个线程修改 volatile 变量后,修改会立即刷新到主内存;其他线程读这个变量时,会直接从主内存获取最新值,避免线程缓存导致的数据不一致。不过要注意,volatile 不保证原子性,适合传递 “状态信号” 的场景,比如用 volatile boolean flag 控制线程启停。
9. 怎么保证多线程安全?
在 Java 里保证多线程安全,核心是解决共享资源并发访问的问题,常用 3 种核心方式,简单说下:
- synchronized 关键字:原生隐式锁,修饰方法或代码块,同一时间只允许一个线程执行同步代码,还能保证可见性和有序性,比如秒杀里用它保护库存扣减逻辑,避免超卖。
- volatile 关键字:修饰变量,确保变量修改后立刻同步到主内存,其他线程能读到最新值,解决可见性和有序性,但不保证原子性,适合做线程启停的状态信号,比如用 volatile boolean 控制线程开关。
- Lock 接口(如 ReentrantLock):显式锁,比 synchronized 灵活,支持可中断、可超时、公平锁,还能配合 Condition 精细唤醒线程,但要手动在 finally 里释放锁,复杂场景(如需要中断等待)用它更合适。
10. Java中有哪些常用的锁,在什么场景下使用?
- 乐观锁和悲观锁:悲观锁(Pessimistic Locking)通常指在访问数据前就锁定资源,假设最坏的情况,即数据很可能被其他线程修改。
synchronized和ReentrantLock都是悲观锁的例子。乐观锁(Optimistic Locking)通常不锁定资源,而是在更新数据时检查数据是否已被其他线程修改。乐观锁常使用版本号或时间戳来实现。 - 内置锁(synchronized):Java中的
synchronized关键字是内置锁机制的基础,可以用于方法或代码块。当一个线程进入synchronized代码块或方法时,它会获取关联对象的锁;当线程离开该代码块或方法时,锁会被释放。如果其他线程尝试获取同一个对象的锁,它们将被阻塞,直到锁被释放。其中,syncronized加锁时有无锁、偏向锁、轻量级锁和重量级锁几个级别。偏向锁用于当一个线程进入同步块时,如果没有任何其他线程竞争,就会使用偏向锁,以减少锁的开销。轻量级锁使用线程栈上的数据结构,避免了操作系统级别的锁。重量级锁则涉及操作系统级的互斥锁。 - 读写锁(ReadWriteLock):
java.util.concurrent.locks.ReadWriteLock接口定义了一种锁,允许多个读取者同时访问共享资源,但只允许一个写入者。读写锁通常用于读取远多于写入的情况,以提高并发性。
11. CountDownLatch 是做什么的讲一讲?
CountDownLatch 是 Java 并发包(java.util.concurrent)中的一个同步工具类,用于让一个或多个线程等待其他线程完成操作后再继续执行。
其核心是通过一个计数器(Counter)实现线程间的协调,常用于多线程任务的分阶段控制或主线程等待多个子线程就绪的场景,核心原理:
- 初始化计数器:创建
CountDownLatch时指定一个初始计数值(如N)。 - 等待线程阻塞:调用
await()的线程会被阻塞,直到计数器变为 0。 - 任务完成通知:其他线程完成任务后调用
countDown(),使计数器减 1。 - 唤醒等待线程:当计数器减到 0 时,所有等待的线程会被唤醒。
12. 怎么理解可重入锁?
可重入锁是指同一个线程在获取了锁之后,可以再次重复获取该锁而不会造成死锁或其他问题。当一个线程持有锁时,如果再次尝试获取该锁,就会成功获取而不会被阻塞。
ReentrantLock实现可重入锁的机制是基于线程持有锁的计数器。
- 当一个线程第一次获取锁时,计数器会加1,表示该线程持有了锁。在此之后,如果同一个线程再次获取锁,计数器会再次加1。每次线程成功获取锁时,都会将计数器加1。
- 当线程释放锁时,计数器会相应地减1。只有当计数器减到0时,锁才会完全释放,其他线程才有机会获取锁。
这种计数器的设计使得同一个线程可以多次获取同一个锁,而不会造成死锁或其他问题。每次获取锁时,计数器加1;每次释放锁时,计数器减1。只有当计数器减到0时,锁才会完全释放。
ReentrantLock通过这种计数器的方式,实现了可重入锁的机制。它允许同一个线程多次获取同一个锁,并且能够正确地处理锁的获取和释放,避免了死锁和其他并发问题。
13. 悲观锁和乐观锁的区别?
- 乐观锁: 就像它的名字一样,对于并发间操作产生的线程安全问题持乐观状态,乐观锁认为竞争不总 是会发生,因此它不需要持有锁,将比较-替换这两个动作作为一个原子操作尝试去修改内存中的变量,如果失败则表示发生冲突,那么就应该有相应的重试逻辑。
- 悲观锁: 还是像它的名字一样,对于并发间操作产生的线程安全问题持悲观状态,悲观锁认为竞争总 是会发生,因此每次对某资源进行操作时,都会持有一个独占的锁,就像 synchronized,不管三七二十一,直接上了锁就操作资源了
14. volatile和sychronized比较?
ynchronized解决了多线程访问共享资源时可能出现的竞态条件和数据不一致的问题,保证了线程安全性。Volatile解决了变量在多线程环境下的可见性和有序性问题,确保了变量的修改对其他线程是可见的。
- Synchronized: Synchronized是一种排他性的同步机制,保证了多个线程访问共享资源时的互斥性,即同一时刻只允许一个线程访问共享资源。通过对代码块或方法添加Synchronized关键字来实现同步。
- Volatile: Volatile是一种轻量级的同步机制,用来保证变量的可见性和禁止指令重排序。当一个变量被声明为Volatile时,线程在读取该变量时会直接从内存中读取,而不会使用缓存,同时对该变量的写操作会立即刷回主内存,而不是缓存在本地内存中
15. 什么是公平锁和非公平锁
- 公平锁: 指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。公平锁的优点在于各个线程公平平等,每个线程等待一段时间后,都有执行的机会,而它的缺点就在于整体执行速度更慢,吞吐量更小。
- 非公平锁: 多个线程加锁时直接尝试获取锁,能抢到锁到直接占有锁,抢不到才会到等待队列的队尾等待。非公平锁的优势就在于整体执行速度更快,吞吐量更大,但同时也可能产生线程饥饿问题,也就是说如果一直有线程插队,那么在等待队列中的线程可能长时间得不到运行
16. 什么情况会产生死锁问题?如何解决
在 Java 多线程里,死锁简单说就是 “线程互相拿着对方要的资源,谁都不放,一直堵着”,得同时满足四个条件才会发生:
第一是互斥,比如资源只能被一个线程用(像独占锁);第二是持有并等待,线程拿着自己的资源,还等着要别人的,自己的又不放手;第三是不可剥夺,线程没主动释放,别人抢不走它手里的资源;第四是环路等待,比如线程 A 等线程 B 的资源,线程 B 又等线程 A 的,形成循环。
解决的话,核心就是打破这四个条件里的任意一个,最常用也最容易落地的是资源有序分配—— 比如给所有资源编个号,线程必须按从小到大的顺序申请资源。比如资源 1 编号 1、资源 2 编号 2,不管哪个线程,都得先拿 1 再拿 2,这样就不会出现 A 拿 1 等 2、B 拿 2 等 1 的环路,直接打破 “环路等待” 条件,从根源避免死锁
17. 介绍一下线程池的工作原理
线程池分为核心线程池,线程池的最大容量,还有等待任务的队列,提交一个任务,如果核心线程没有满,就创建一个线程,如果满了,就是会加入等待队列,如果等待队列满了,就会增加线程,如果达到最大线程数量,如果都达到最大线程数量,就会按照一些丢弃的策略进行处理。
四、 JVM
1. JVM内存模型里的堆和栈有什么区别?
- 用途:栈主要用于存储局部变量、方法调用的参数、方法返回地址以及一些临时数据。每当一个方法被调用,一个栈帧(stack frame)就会在栈中创建,用于存储该方法的信息,当方法执行完毕,栈帧也会被移除。堆用于存储对象的实例(包括类的实例和数组)。当你使用
new关键字创建一个对象时,对象的实例就会在堆上分配空间。 - 生命周期:栈中的数据具有确定的生命周期,当一个方法调用结束时,其对应的栈帧就会被销毁,栈中存储的局部变量也会随之消失。堆中的对象生命周期不确定,对象会在垃圾回收机制(Garbage Collection, GC)检测到对象不再被引用时才被回收。
- 存取速度:栈的存取速度通常比堆快,因为栈遵循先进后出(LIFO, Last In First Out)的原则,操作简单快速。堆的存取速度相对较慢,因为对象在堆上的分配和回收需要更多的时间,而且垃圾回收机制的运行也会影响性能。
- 存储空间:栈的空间相对较小,且固定,由操作系统管理。当栈溢出时,通常是因为递归过深或局部变量过大。堆的空间较大,动态扩展,由JVM管理。堆溢出通常是由于创建了太多的大对象或未能及时回收不再使用的对象。
- 可见性:栈中的数据对线程是私有的,每个线程有自己的栈空间。堆中的数据对线程是共享的,所有线程都可以访问堆上的对象
2. 堆分为哪几部分呢?
- 新生代(Young Generation):新生代分为Eden Space和Survivor Space。在Eden Space中, 大多数新创建的对象首先存放在这里。Eden区相对较小,当Eden区满时,会触发一次Minor GC(新生代垃圾回收)。在Survivor Spaces中,通常分为两个相等大小的区域,称为S0(Survivor 0)和S1(Survivor 1)。在每次Minor GC后,存活下来的对象会被移动到其中一个Survivor空间,以继续它们的生命周期。这两个区域轮流充当对象的中转站,帮助区分短暂存活的对象和长期存活的对象。
- 老年代(Old Generation/Tenured Generation):存放过一次或多次Minor GC仍存活的对象会被移动到老年代。老年代中的对象生命周期较长,因此Major GC(也称为Full GC,涉及老年代的垃圾回收)发生的频率相对较低,但其执行时间通常比Minor GC长。老年代的空间通常比新生代大,以存储更多的长期存活对象。
- 元空间(Metaspace):从Java 8开始,永久代(Permanent Generation)被元空间取代,用于存储类的元数据信息,如类的结构信息(如字段、方法信息等)。元空间并不在Java堆中,而是使用本地内存,这解决了永久代容易出现的内存溢出问题。
- 大对象区(Large Object Space / Humongous Objects):在某些JVM实现中(如G1垃圾收集器),为大对象分配了专门的区域,称为大对象区或Humongous Objects区域。大对象是指需要大量连续内存空间的对象,如大数组。这类对象直接分配在老年代,以避免因频繁的年轻代晋升而导致的内存碎片化问题
1. 微服务中如何调用两个表的数据呢(数据库该怎么关联)
在微服务架构中,跨服务的表数据关联是很常见的场景,我们通常遵循 “服务边界清晰” 的原则来处理,而不是直接做数据库层面的关联。
具体来说,会通过 OpenFeign 实现服务间的声明式调用:比如订单服务需要关联用户服务的数据时,订单服务先查询本地订单表获取用户 ID,然后通过定义好的 Feign 客户端调用用户服务的接口,传入用户 ID 获取对应的用户信息,最后在订单服务的业务层完成数据聚合。
这种方式的优势在于:每个服务只维护自己的数据库,符合微服务 “数据自治” 的设计理念,避免了跨库 JOIN 带来的性能损耗和分布式事务问题,也让服务间的依赖关系更清晰。






