Java面试

 

目录

web开发基础

说一下你熟悉的设计原则和设计模式

说说你对红黑树的理解

Java基础

抽象类和接口的区别

hashcode()值相同,equals就一定为true

为什么重写equals(),就要重写hashcode()?

short s = 1;s = s + 1;(程序1)和 short s = 1; s += 1;(程序2)是否都能正常运行

说出下面程序的运行结果,及原因

Error和Exception有什么区别

NoClassDefFoundError和ClassNotFoundException区别

如果try{} 里有一个 return 语句,那么finally{} 里的代码会不会被执行,什么时候被执行,在 return 前还是后?

说说final关键字的作用

finally是什么

finalize简介

说说Java中== 和 equals

反射的机制和应用场景

Java集合

List、Set、Map的区别

List、Set、Map常用集合有哪些?

ArrayList的初始容量是多少?扩容机制是什么?扩容过程是怎样?

set有什么特点?为什么set不允许重复?

什么是hashmap

什么是哈希冲突/哈希碰撞?

Hashmap的底层实现机制

简单说说hashmap的加载因子为什么是0.75?

为什么HashMap的加载因子不能是0.8或0.6?

能否自定义HashMap的加载因子?

为什么HashMap的初始容量和扩容都是2的次幂

HashMap如果指定了不是2的次幂的容量会发生什么?

HashMap为什么线程不安全

如何解决Hashmap的线程安全问题

为什么用synchronized代替ReentrantLock

HashMap为什么使用链表

HashMap为什么使用红黑树

HashMap为什么不一上来就使用红黑树

Java并发与多线程

说说并发与并行的区别

说说你对线程安全的理解

什么是进程和线程?

线程有几种创建方式?

一个8G的系统能创建多少个线程?

启动一个Java程序,里面会有哪些线程?

调用 start()方法时会执行 run()方法,那怎么不直接调用 run()方法?

start() 与 run() 的区别

为什么不能直接调用 run() 方法

说说线程中的等待与通知

说说sleep(long millis)方法

说说yield()方法

说说线程中断

说说线程有几种状态

什么是上下文切换?

什么是守护线程?

线程间的通信方式有哪些?

说说sleep和wait方法的区别

如何保证线程安全?

有个int的变量为0,十个线程轮流对其进行++操作(循环10000次),结果是大于小于还是等于10万,为什么?

有一个 key 对应的 value 是一个json 结构,json 当中有好几个子任务,这些子任务如果对 key 进行修改的话,会不会存在线程安全的问题?如何解决?如果是多个节点的情况,应该怎么加锁?

说一个线程安全的使用场景?

什么是ThreadLocal?

除了 ThreadLocal,还有什么解决线程安全问题的方法?

ThreadLocal是怎么实现的?

举例说明一些Threadlocal的应用场景

ThreadLocal 内存泄露是怎么回事?

那怎么解决内存泄漏问题呢?

ThreadLocalMap 的底层实现机制

ThreadLocalMap 怎么解决 Hash 冲突的?


web开发基础

说一下你熟悉的设计原则和设计模式

SOLID原则:

Single responselibitlity

Open-closed

Liskov Substitution

Interface segregation

Dependency injection


单例模式: 保证被创建一次,节省系统开销。
工厂模式: 解耦代码。
观察者模式: 定义了对象之间的一对多的依赖,这样一来,当一个对象改变时,它的所有的依赖者都会收到通知并自动更新。
代理模式: 代理对象具备被代理对象的功能,并代替被代理对象完成相应操作,并能够在操作执行的前后,对操作进行增强处理。
模板模式: 较少代码冗余。例如:redis模板。

说说你对红黑树的理解

①根节点是黑色。
②节点是黑色或红色。
③叶子节点是黑色。
④红色节点的子节点都是黑色。
⑤从任意节点到其子节点的所有路径都包含相同数目的黑色节点。红黑树从根到叶子节点的最长路径不会超过最短路径的2倍。保证了红黑树的高效。

Java基础

抽象类和接口的区别

相同点:都是不断抽取出来的抽象概念

区别:

  • 接口是行为的抽象,是一种行为的规范,接口是like a 的关系;抽象类是对类的抽象,是一种模板设计,抽象类是is a 的关系。
  • 接口没有构造方法,而抽象类有构造方法,其方法一般给子类使用
  • 接口只有方法定义,不能有方法的实现,java 1.8中可以定义default方法体,而抽象类可以有定义与实现,方法可在抽象类中实现。
  • 抽象类体现出了继承关系,继承只能单继承。接口提现出来了实现的关系,实现可以多实现。接口强调特定功能的实现,而抽象类强调所属关系。
  • 接口成员变量默认为public static final,必须赋初值,不能被修改;其所有的成员方法都是public abstract的。抽象类中成员变量默认default,可在子类中被重新定义,也可被重新赋值;抽象方法被abstract修饰,不能被private、static、synchronized和native等修饰,必须以分号结尾,不带花括号。

注:JDK1.8中对接口增加了新的特性:
(1)默认方法(default method):JDK 1.8允许给接口添加非抽象的方法实现,但必须使用default关键字修饰;定义了default的方法可以不被实现子类所实现,但只能被实现子类的对象调用;如果子类实现了多个接口,并且这些接口包含一样的默认方法,则子类必须重写默认方法;
(2)静态方法(static method):JDK 1.8中允许使用static关键字修饰一个方法,并提供实现,称为接口静态方法。接口静态方法只能通过接口调用(接口名.静态方法名)。

hashcode()值相同,equals就一定为true


不一定,因为 "重地"和"通话"的hashcode值就相同,但是equals()就为false。
但是equals()为true,那么hashcode一定相同。

为什么重写equals(),就要重写hashcode()?


保证同一对象,如果不重写hashcode,可能会出现equals比较一样,但是hashcode不一样的情况。

short s = 1;s = s + 1;(程序1)和 short s = 1; s += 1;(程序2)是否都能正常运行


程序1会编译报错,因为 s + 1的1是int类型,因为类型不兼容。强制转换失败。
程序2可以正常运行,因为java在复合赋值解释是 E1 += E2,等价于 E1 = (T)(E1 + E2),T是E1的类型,因此s += 1等价于 s = (short)(s + 1),所以进行了强制类型的转换,所以可以正常编译。

说出下面程序的运行结果,及原因

public static void main(String[] args) {
    Integer a = 128, b = 128, c = 127, d = 127;
    System.out.println(a == b);
    System.out.println(c == d);
}

结果:false,true

因为Integer = a,相当于自动装箱(基础类型转为包装类),因为Integer引入了IntegerCache来缓存一定的值,IntegerCache默认是 -128~127,所以128超过了范围,a和b不是相同对象,c和d是相同对象。可以通过jvm启动时,修改缓存的上限。

Error和Exception有什么区别

  • Error: 程序无法处理,比较严重的问题,程序会立即崩溃,jvm停止运行。
  • Exception: 程序本身可以处理(向上抛出或者捕获),分为编译时异常和运行时异常

NoClassDefFoundError和ClassNotFoundException区别

  • NoClassDefFoundError: 在打包时漏掉了某些类或者打包时存在,然后你把target里的类删除,然后jvm运行时找不到报错。
  • ClassNotFoundException: 在编译的时候某些类找不到,然后报错。

如果try{} 里有一个 return 语句,那么finally{} 里的代码会不会被执行,什么时候被执行,在 return 前还是后?


会执行,在return之前执行,如果finally有return那么try的return就会失效。

说说final关键字的作用

1、final可以用来修饰的结构:类、方法、变量

2、final用来修饰一个类:此类不能被其它类继承。当我们需要让一个类永远不被继承,此时就可以用final修饰,但要注意:final类中所有的成员方法都会隐式的定义为final方法。

比如:String类、System类、StringBuffer类

3、final  用来修饰方法  :表明此方法不可以被重写 

作用​​​​
(1) 把方法锁定,以防止继承类对其进行更改。

(2) 效率,在早期的java版本中,会将final方法转为内嵌调用。但若方法过于庞大,可能在性能上不会有多大提升。因此在最近版本中,不需要final方法进行这些优化了。

final方法意味着“最后的、最终的”含义,即此方法不能被重写。

比如:Object类中的getClass( )

4、final 用来修饰变量 ,此时变量就相当于常量

final用来修饰属性:可以考虑赋值的位置有:显式初始化、代码块中初始化、构造器中初始化

final修饰局部变量:尤其是使用final修饰形参时,表明此形参是一个常量。当我们调用此方法时,给常量形参赋一个实参,一旦赋值之后,就只能在方法体内使用此形参的值,不能重新进行赋值。

如果final修饰一个引用类型时,则在对其初始化之后便不能再让其指向其他对象了或者说他的地址不能发生变化了(因为引用的值是一个地址,final要求值,即地址的值不发生变化),但该引用所指向的对象的内容是可以发生变化的。本质上是一回事。

5、static  final  用来修饰属性:属于类的资源-全局变量,static就是类在被加载进内存的时候(也就是应用程序启动的时候)就要已经为此属性分配了内存,所以此时属性已经存在,它又被final修饰,所以必须在属性定义了以后就给其初始化值。

而构造函数是在当类被实例化的时候才会执行,所以用构造函数,这时候这个属性没有被初始化,程序就会报错。

而static块是类被加载的时候执行,且只执行这一次,所以在static块中可以被初始化。

finally是什么


异常处理机制:try ——>catch ——>finally(在方法中)

异常的处理:抓抛模型。

说明:

1、finally是可选的,

2、使用try将可能出现异常代码包装起来,在执行过程中,一旦出现异常,就会生成一个对应一个异常类的对象。

一旦try中

catch中的异常类型如果没有子父类关系,则谁声明在上,谁声明在下没有关系。

catch中的异常类型如果满足子父类关系,则要求子类一定要声明在父类的上面,否则,报错、

常用的异常对象的处理方式:

String getMessage() 

printStackTrace()

常用的异常对象处理的方式,再出了try结构以后,就不能再被调用。

try—catch是可以嵌套的。

finally的使用:一定会被执行的代码:

可以不写

即使catch 中又出现异常了,try中有return语句,catch中有return语句等情况。

finally使用的场景:

数据库连接

输入输出流

网络编程Socket等资源,JVM是不能自动的回收的。我们需要手动的进行资源释放。此时就需要放在finally中

finalize简介


 1. finalize定义
        finalize()是在java.lang.Object里定义的protected方法,子类可以覆盖该方法以实现资源清理工作,GC在回收对象之前调用该方法。也就是说每一个对象都有这么个方法。

        这个方法在GC启动,该对象被回收的时候被调用。其实GC可以回收大部分的对象(凡是new出来的对象,GC都能搞定,一般情况下我们又不会用new以外的方式去创建对象),所以一般是不需要程序员去实现finalize的。 
特殊情况下,需要程序员实现finalize,当对象被回收的时候释放一些资源,比如:一个socket链接,在对象初始化时创建,整个生命周期内有效,那么就需要实现finalize,关闭这个链接。 
  使用finalize还需要注意一个事,调用super.finalize();

  一个对象的finalize()方法只会被调用一次,而且finalize()被调用不意味着GC会立即回收该对象,所以有可能调用finalize()后,该对象又不需要被回收了,然后到了真正要被回收的时候,因为前面调用过一次,所以不会调用finalize(),产生问题。 所以,推荐不要使用finalize()方法,它跟析构函数不一样。

2. finalize的执行过程(生命周期)
(1) 首先,描述finalize大致流程:当对象变成(GC Roots)不可达时,GC会判断该对象是否覆盖了finalize方法,若未覆盖,则直接将其回收。

        否则,若对象未执行过finalize方法,将其放入F-Queue队列,由一低优先级线程执行该队列中对象的finalize方法。

        执行finalize方法完毕后,GC会再次判断该对象是否可达,若不可达,则进行回收,否则,对象“复活”。

(2) 具体的finalize

   

各状态含义如下:

unfinalized:新建对象会先进入此状态,GC并未准备执行其finalize方法,因为该对象是可达的

finalizable:表示GC可对该对象执行finalize方法,GC已检测到该对象不可达。正如前面所述,GC通过F-Queue队列和一专用线程完成finalize的执行

finalized:表示GC已经对该对象执行过finalize方法

reachable:表示GC Roots引用可达

finalizer-reachable(f-reachable):表示不是reachable,但可通过某个finalizable对象可达

unreachable:对象不可通过上面两种途径可达

说说Java中== 和 equals

在 Java 中,"==" 和 "equals" 有着不同的作用:

"==" 运算符:

    在基本数据类型(如 int、char 等)中,"==" 用于比较它们的值是否相等。

    在引用类型中,"==" 比较的是对象引用(即内存地址)是否相同,即是否指向同一块内存。

"equals" 方法:

    "equals" 是一个方法,用于比较对象的内容是否相等,它是 Object 类的方法,可以被子类覆盖重写。

默认情况下,Object 类中的 "equals" 方法是使用 "==" 比较两个对象的引用,因此如果没有在类中重写 "equals" 方法,那么使用 "equals" 和 "==" 会有相同的行为。

在源码中,"==" 实际上是在比较对象的引用,即比较两个对象的内存地址是否相同。这在 Java 虚拟机中通过比较对象的引用地址来实现。这是因为 "==" 操作符比较的是两个对象的引用,如果两个对象的引用指向的是同一个内存地址,则返回 true。

对于 "equals" 方法,它是一个可以被覆盖重写的方法。在 Object 类中的默认实现是直接使用 "==" 操作符来比较两个对象的引用地址。但是,许多类(如 String、Integer 等)会覆盖 "equals" 方法以实现内容比较而非引用比较。因此,"equals" 方法的实现可以根据类的需求而有所不同。

"==" 比较对象的引用,而 "equals" 方法用于比较对象的内容,但前提是需要根据需要在类中重写 "equals" 方法。

反射的机制和应用场景

     基本概念
    Java中的反射机制是指在运行时访问类的信息和操作类的能力。通过反射,程序可以获取类的所有属性和方法,创建对象的实例,调用方法,甚至修改私有属性的值。反射机制提供了一种动态处理类的方式,使得程序能够在运行时灵活地创建和使用对象。

   反射机制的组成部分

  •     Class类:java.lang.Class类是反射机制的基础。每个类都有一个与之关联的Class对象,可以通过Class.forName()方法或类名.class属性获得。
  •     Field类:用于表示和操作类的属性。可以通过Class对象获取Field对象的数组,进而获取或设置字段的值。
  •     Method类:用于表示和调用类的方法。可以通过Class对象获取Method对象的数组,然后调用invoke()方法来执行特定的方法。
  •     Constructor类:用于创建类的实例。可以通过Class对象获取Constructor对象,然后调用newInstance()方法来创建对象。
  •     Modifier类:提供了一系列的常量,用于解码类的修饰符(如public、private等)。

    反射机制的用途

  •     动态创建对象:在运行时,可以根据字符串名称来创建对象的实例,而不需要在编译时确定。
  •     动态调用方法:可以动态地调用对象的方法,即使这些方法在编写代码时还未知。
  •     动态访问属性:可以获取和设置对象的属性,包括私有属性。
  •     获取类的信息:可以获取类的名称、父类、实现的接口、注解等信息。
  •     实现通用代码:可以编写一些通用的代码,这些代码可以在运行时指定具体要操作的类。

    经典应用场景

  •     框架和库:许多框架和库使用反射来实现依赖注入、动态代理等功能。
  •     动态代理:Java提供了java.lang.reflect.Proxy类和InvocationHandler接口来创建动态代理对象,这在实现AOP(面向切面编程)时非常有用。
  •     JSON序列化和反序列化:在处理JSON数据时,可以使用反射来将JSON对象映射到Java对象,或者将Java对象转换为JSON字符串。
  •     数据库ORM框架:对象关系映射(ORM)框架(如Hibernate、MyBatis)使用反射来动态地访问对象的属性,并将其映射到数据库表的列。
  •     单元测试:在单元测试中,反射可以用来访问和测试私有方法和属性。

Java集合


java集合框架详解

HashMap底层原理详解

List、Set、Map的区别


List集合有序、可重复的单例集合。
Set集合无序、不可重复的单例集合。
Map集合无序、k不可重复,v可重复的双例集合。

List、Set、Map常用集合有哪些?


List

  • vector: 底层是数组,方法加了synchronized来保证线程安全,所以效率较慢,使用ArrayList替代。
  • ArrayList: 线程不安全,底层是数组,因为数组都是连续的地址,所以查询比较快。增删比较慢,增会生成一个新数组,把新增的元素和原有元素放到新数组中,删除会导致元素移动,所以增删速度较慢。
  • LinkedList: 线程不安全,底层是链表,因为地址不是连续的,都是一个节点和一个节点相连,每次查询都得重头开始查询,所以查询慢,增删只是断裂某个节点对整体影响不大,所以增删速度较快。

Set

  • HashSet: 底层是哈希表(数组+链表或数组+红黑树),在链表长度大于8时转为红黑树,在红黑树节点小于6时转为链表。其实就是实现了HashMap,值存入key,value是一个final修饰的对象。
  • TreeSet: 底层是红黑树结构,就是TreeMap实现,可以实现有序的集合。String和Integer可以根据值进行排序。如果是对象需要实现Comparator接口,重写compareTo()方法制定比较规则。
  • LinkedHashSet: 实现了HashSet,多一条链表来记录位置,所以是有序的。

Map双例结构

  • TreeMap: 底层是红黑树,key可以按顺序排列。
  • HashMap: 底层是哈希表,可以很快的储存和检索,无序,大量迭代情况不佳。
  • LinkedHashMap: 底层是哈希表+链表,有序,大量迭代情况佳。

ArrayList的初始容量是多少?扩容机制是什么?扩容过程是怎样?

  • 初始容量: 默认10,也可以通过构造方法传入大小。
  • 扩容机制: 原数组长度 + 原数组长度/2(源码中是原数组右移一位,也就相当于除以2)

       注意:扩容后的ArrayList底层数组不是原来的数组。

  • 扩容过程: 因为ArrayList底层是数组,所以它的扩容机制和数组一样,首先新建一个新数组,长度是原数组的1.5倍,然后调用Arrays.copyof()复制原数组的值,然后赋值给新数组。

set有什么特点?为什么set不允许重复?

set存储元素是无序不重复的。

hashCode()方法:

因为当我们向Set中添加元素时,首先会调用该对象的hashCode()方法,计算并返回一个整数(哈希值)。为了提高比较效率,我们需要先进行hash计算。如果两个对象的hash值不同,则表示当前是两个对象,但是如果两个对象的hash值是相同的,则这两个对象可能相同,此时在进行equals。

equals()方法:

如果两个对象的哈希值相同,Set会进一步调用equals()方法,来确定这两个对象是否真正相同。只有当equals()方法返回false时,才将新对象添加到集合中

Set的实现机制

Java中的Set接口有多种实现类,如HashSet、LinkedHashSet和TreeSet等。这些实现类通过不同的机制来保证元素的唯一性。

HashSet:
HashSet是基于哈希表实现的。它使用哈希函数来计算元素的哈希值,并根据哈希值将元素存储在哈希表的相应位置。由于哈希表的特性,每个位置只能存储一个元素(或称为桶),因此HashSet可以保证元素的唯一性。

LinkedHashSet:
LinkedHashSet是HashSet的一个子类,它继承了HashSet的所有特性,并额外维护了一个双向链表来记录元素的插入顺序。尽管LinkedHashSet保留了元素的插入顺序,但它仍然保证了元素的唯一性。

TreeSet:
TreeSet是基于红黑树实现的。红黑树是一种自平衡的二叉搜索树,它可以在O(log n)的时间复杂度内完成元素的插入、删除和查找操作。TreeSet通过红黑树的特性来保证元素的唯一性和有序性。


                        

什么是hashmap

根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把key值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。

什么是哈希冲突/哈希碰撞?

就是键(key)经过hash函数得到的结果作为地址去存放当前的键值对,但是却发现该地址已经被占用,这个时候就会发生冲突。这个冲突就是hash冲突了。

概括:如果两个不同对象的hashCode相同,这种现象称为哈希冲突。

解决hash冲突的方法

(1) 开放地址法

插入一个元素的时候,先通过哈希函数进行判断,若是发生哈希冲突,就以当前地址为基准,根据再寻址的方法(探查序列),去寻找下一个地址,若发生冲突再去寻找,直至找到一个为空的地址为止。所以这种方法又称为再散列法。

(2) 再哈希法
再哈希法又叫双哈希法,有多个不同的Hash函数,当发生冲突时,使用第二个,第三个,….,等哈希函数计算地址,直到无冲突。虽然不易发生聚集,但是增加了计算时间。

(3) 链地址法
每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单向链表连接起来。

(4)建立公共溢出区

将哈希表分为基本表和溢出表两部分,凡是和基本表发生冲突的元素,一律填入溢出表。

Hashmap的底层实现机制

  1.  在JDK1.8之前,HashMap采用数组+链表实现,即使用链表处理冲突,相同hash值的元素都存储在一个链表里。但是当hash值相等的元素较多时,通过key值依次查找的效率较低;链表是为了解决哈希冲突而存在内部解决方案(拉链法);
  2.  而JDK1.8中,HashMap在原来的基础上引入了红黑树,当链表长度超过阈值(8)并且数组容量达到一定长度(默认是16)时,将链表转换为红黑树,这样大大减少了查找时间;链表长度大于8时转化为红黑树,小于6时转化为链表;

简单说说hashmap的加载因子为什么是0.75?

加载因子是HashMap中一个重要的参数,它决定了HashMap在什么时候进行扩容。加载因子的值越小,哈希表的冲突几率就越小,查询效率越高。但是如果加载因子设置得太小,会导致哈希表频繁地进行扩容,增加了时间和空间的开销。相反,如果加载因子设置得太大,会增加哈希冲突的几率,降低查询效率。

所以,为了权衡哈希冲突的几率和查询效率的平衡,Java中的HashMap实现选择了0.75作为默认的加载因子。这个值既能有效地减少哈希冲突的几率,又能保持较高的查询效率。根据实际需求,我们也可以根据业务场景调整加载因子的大小,以获得更好的性能。

为什么HashMap的加载因子不能是0.8或0.6?

虽然加载因子可以根据实际需求进行调整,但为什么HashMap的加载因子不能选择0.8或0.6这样的值呢?这是因为在哈希冲突的情况下,加载因子的值会直接影响到HashMap的性能。

当加载因子值较大(如0.8)时,哈希表的填充率较高,冲突的几率增加。这会导致链表长度的增加,进而降低查询和插入的效率。同时,加载因子越小,扩容的频率也会增加,导致性能下降。另一方面,如果加载因子值较小(如0.6),哈希表的填充率较低,可能会导致内存的浪费。

因此,在综合考虑哈希冲突和性能的情况下,选择0.75作为HashMap的默认加载因子更加合适,能够在时间和空间的开销上取得一个比较好的平衡。

能否自定义HashMap的加载因子?

        Java中的HashMap类允许我们在创建HashMap实例时自定义加载因子。通过指定不同的加载因子值,可以根据实际需求调整HashMap的性能。例如,如果我们需要更高的查询效率,可以将加载因子设定为较小的值(如0.5或0.6);如果我们更加关注内存的使用,可以将加载因子设定为较大的值(如0.8或0.9)。

        HashMap的hash()算法,为什么不是h=key.hashcode(),而是key.hashcode()^ (h>>>16)
得到哈希值然后右移16位,然后进行异或运算,这样使哈希值的低16位也具有了一部分高16位的特性,增加更多的变化性,减少了哈希冲突。

为什么HashMap的初始容量和扩容都是2的次幂

  • 因为计算元素存储的下标是(n-1)&哈希值,数组初始容量-1,得到的二进制都是1,这样可以减少哈希冲突,可以更好的均匀插入。

HashMap如果指定了不是2的次幂的容量会发生什么?

  • 会获得一个大于指定的初始值的最接近2的次幂的值作为初始容量。

HashMap为什么线程不安全

  • jdk1.7中因为使用头插法,再扩容的时候,可能会造成闭环和数据丢失。
  • jdk1.8中使用尾插法,不会出现闭环和数据丢失,但是在多线程下,会发生数据覆盖。(put操作中,在putVal函数里) 值的覆盖还有长度的覆盖。

如何解决Hashmap的线程安全问题


        (1)使用Hashtable解决,在方法加同步关键字,所以效率低下,已经被弃用。
        (2)使用Collections.synchronizedMap(new HashMap<>()),不常用。
        (3)ConcurrentHashMap(常用)

说说ConcurrentHashMap的原理

  • jdk1.7: 采用分段锁,是由Segment(继承ReentrantLock:可重入锁,默认是16,并发度是16)和HashEntry内部类组成,每一个Segment(锁)对应1个HashEntry(key,value)数组,数组之间互不影响,实现了并发访问。
  • jdk1.8: 抛弃分段锁,采用CAS(乐观锁)+synchronized实现更加细粒度的锁,Node数组+链表+红黑树结构。只要锁住链表的头节点(树的根节点),就不会影响其他数组的读写,提高了并发度。

为什么用synchronized代替ReentrantLock


        ①节省内存开销。ReentrantLock基于AQS来获得同步支持,但不是每个节点都需要同步支持,只有链表头节点或树的根节点需要同步,所以使用ReentrantLock会带来很大的内存开销。
        ②获得jvm支持,可重入锁只是api级别,而synchronized是jvm直接支持的,能够在jvm运行时做出相应的优化。
        ③在jdk1.6之后,对synchronized做了大量的优化,而且有多种锁状态,会从 无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁一步步转换。

AQS (Abstract Queued Synchronizer): 一个抽象的队列同步器,通过维护一个共享资源状态( Volatile Int State )和一个先进先出( FIFO )的线程等待队列来实现一个多线程访问共享资源的同步框架。

HashMap为什么使用链表

  • 减少和解决哈希冲突,把冲突的值放在同一链表下。

HashMap为什么使用红黑树

  • 当数据过多,链表遍历较慢,所以引入红黑树。

HashMap为什么不一上来就使用红黑树

  • 维护成本较大,红黑树在插入新的数据后,可能会进行变色、左旋、右旋来保持平衡,所以当数据少时,就不需要红黑树。

Java并发与多线程

说说并发与并行的区别

  • 并行:多核 CPU 上的多任务处理,多个任务在同一时间真正地同时执行。
  • 并发:单核 CPU 上的多任务处理,多个任务在同一时间段内交替执行,通过时间片轮转实现交替执行。

说说你对线程安全的理解

线程安全是指一段代码块或者一个方法在多线程环境中被多个线程同时执行时能够正确地处理共享数据,可以从三个要素来确保线程安全:

①、原子性:确保当某个线程修改共享变量时,没有其他线程可以同时修改这个变量,即这个操作是不可分割的。原子性可以通过互斥锁(如 synchronized)或原子操作(如 AtomicInteger 类中的方法)来保证。使用Lock接口及其实现类‌:如ReentrantLock,通过显式地加锁和解锁来确保操作

②、可见性:可见性是指当一个线程修改了共享变量的值时,这个新值对其他线程来说是立即可见的。在Java中,保证可见性的方法包括:

  • 使用volatile关键字‌:声明为volatile的变量在修改后会立即对所有线程可见。
  • 使用synchronized关键字‌:同步方法或代码块在释放锁时会刷新共享变量的值,确保其他线程能看到最新的值。
  • 使用final关键字‌:对于不可变对象,使用final关键字可以确保对象的引用不变,从而保证可见性。

③、有序性:有序性是指程序执行的顺序必须符合预期,不能出现乱序的情况。Java内存模型通过以下机制保证有序性:

  • 禁止指令重排序‌:volatile关键字可以防止指令重排序优化,确保程序的执行顺序与代码的顺序相同。
  • 使用synchronizedvolatile关键字‌:这些关键字在编译时会被处理以确保内存操作的顺序性。
  • 使用LocksCondition‌:通过显式锁定和解锁操作,可以更灵活地控制有序性。

 

什么是进程和线程?

进程说简单点就是我们在电脑上启动的一个个应用。它是操作系统分配资源的最小单位。

线程是进程中的独立执行单元。多个线程可以共享同一个进程的资源,如内存;每个线程都有自己独立的栈和寄存器。

线程有几种创建方式?

  • 继承Thread类,重写run方法,调用start()方法启动线程
  • 实现runnable接口,重写 run() 方法,然后创建 Thread 对象,将 Runnable 对象作为参数传递给 Thread 对象,调用 start() 方法启动线程。
  • 实现callable接口,重写 call() 方法,然后创建 FutureTask 对象,参数为 Callable 对象;紧接着创建 Thread 对象,参数为 FutureTask 对象,调用 start() 方法启动线程。
  • 通过线程池创建。

一个8G的系统能创建多少个线程?

在确定一个系统最多可以创建多个线程时,除了需要考虑系统的内存大小外,Java 虚拟机栈的大小也是值得考虑的因素。线程在创建的时候会被分配一个虚拟机栈,在 64 位操作系统中,默认大小为 1M。通过 java -XX:+PrintFlagsFinal -version | grep ThreadStackSize 这个命令可以查看 JVM 栈的默认大小。其中 ThreadStackSize 的单位是字节,也就是说默认的 JVM 栈大小是 1024 KB,也就是 1M。换句话说,8GB = 8 _ 1024 MB = 8 _ 1024 _ 1024 KB,所以一个 8G 内存的系统可以创建的线程数为 8 _ 1024 = 8192 个。但操作系统本身的运行也需要消耗一定的内存,所以实际上可以创建的线程数肯定会比 8192 少一些。

启动一个Java程序,里面会有哪些线程?

首先是 main 线程,这是程序开始执行的入口。

然后是垃圾回收线程,它是一个后台线程,负责回收不再使用的对象。

还有编译器线程,在及时编译中(JIT),负责把一部分热点代码编译后放到 codeCache 中,以提升程序的执行效率。

  • Thread: main (ID=1) - 主线程,Java 程序启动时由 JVM 创建。
  • Thread: Reference Handler (ID=2) - 这个线程是用来处理引用对象的,如软引用(SoftReference)、弱引用(WeakReference)和虚引用(PhantomReference)。负责清理被 JVM 回收的对象。
  • Thread: Finalizer (ID=3) - 终结器线程,负责调用对象的 finalize 方法。对象在垃圾回收器标记为可回收之前,由该线程执行其 finalize 方法,用于执行特定的资源释放操作。
  • Thread: Signal Dispatcher (ID=4) - 信号调度线程,处理来自操作系统的信号,将它们转发给 JVM 进行进一步处理,例如响应中断、停止等信号。
  • Thread: Monitor Ctrl-Break (ID=5) - 监视器线程,通常由一些特定的 IDE 创建,用于在开发过程中监控和管理程序执行或者处理中断。

调用 start()方法时会执行 run()方法,那怎么不直接调用 run()方法?

在 Java 中,启动一个新的线程应该调用其start()方法,而不是直接调用run()方法。

当调用start()方法时,会启动一个新的线程,并让这个新线程调用run()方法。这样,run()方法就在新的线程中运行,从而实现多线程并发。

如果直接调用run()方法,那么run()方法就在当前线程中运行,没有新的线程被创建,也就没有实现多线程的效果。

start() 与 run() 的区别

  1. 调用 start() 方法
  • 调用 start() 方法会启动一个新线程,这意味着会分配一个新的执行路径,并在该路径中调用 run() 方法。
  • 调用 start() 后,线程处于就绪状态,等待 JVM 调度器安排 CPU 时间片来执行线程逻辑。
  • 每个线程都独立运行,和其他线程并行或并发。
  1. 调用 run() 方法
  • 调用 run() 方法不会启动新线程,而只是一个普通方法调用。
  • run() 方法中的代码在当前线程中执行,没有并发优势。
  • 调用 run() 不能充分利用多核 CPU 的优势,也不会将线程加入调度队列。

为什么不能直接调用 run() 方法

  • 线程特性丧失:调用 run() 方法时,线程特性丧失。它只是一个简单的方法调用,不涉及创建新线程。
  • 没有并行性:直接调用 run(),会使得代码在主线程中顺序执行,而非并发执行,从而无法利用多线程带来的并行性优势。
  • 不能获得独立的执行环境:start() 方法会为每个线程分配一个独立的执行环境,确保线程互相独立。而直接调用 run() 会导致所有代码都在同一个线程中执行,无法隔离任务。

说说线程中的等待与通知

在 Object 类中有一些方法可以用于线程的等待与通知。

①、wait():当一个线程 A 调用一个共享变量的 wait() 方法时,线程 A 会被阻塞挂起,直到发生下面几种情况才会返回 :

  • 线程 B 调用了共享对象 notify()或者 notifyAll() 方法;
  • 其他线程调用了线程 A 的 interrupt() 方法,线程 A 抛出 InterruptedException 异常返回。

②、wait(long timeout) :这个方法相比 wait() 方法多了一个超时参数,它的不同之处在于,如果线程 A 调用共享对象的 wait(long timeout)方法后,没有在指定的 timeout 时间内被其它线程唤醒,那么这个方法还是会因为超时而返回。

③、wait(long timeout, int nanos),其内部调用的是 wait(long timout) 方法。

唤醒线程主要有下面两个方法:

①、notify():一个线程 A 调用共享对象的 notify() 方法后,会唤醒一个在这个共享变量上调用 wait 系列方法后被挂起的线程。

一个共享变量上可能会有多个线程在等待,具体唤醒哪个等待的线程是随机的。

②、notifyAll():不同于在共享变量上调用 notify() 方法会唤醒被阻塞到该共享变量上的一个线程,notifyAll 方法会唤醒所有在该共享变量上调用 wait 系列方法而被挂起的线程。

Thread 类还提供了一个 join() 方法,意思是如果一个线程 A 执行了 thread.join(),当前线程 A 会等待 thread 线程终止之后才从 thread.join() 返回。

说说sleep(long millis)方法

sleep(long millis):Thread 类中的静态方法,当一个执行中的线程 A 调用了 Thread 的 sleep 方法后,线程 A 会暂时让出指定时间的执行权。

但是线程 A 所拥有的监视器资源,比如锁,还是持有不让出的。指定的睡眠时间到了后该方法会正常返回,接着参与 CPU 的调度,获取到 CPU 资源后就可以继续运行

说说yield()方法

yield():Thread 类中的静态方法,当一个线程调用 yield 方法时,实际是在暗示线程调度器,当前线程请求让出自己的 CPU,但是线程调度器可能会“装看不见”忽略这个暗示

说说线程中断

Java 中的线程中断是一种线程间的协作模式,通过设置线程的中断标志并不能直接终止该线程的执行。被中断的线程会根据中断状态自行处理。

  • void interrupt() 方法:中断线程,例如,当线程 A 运行时,线程 B 可以调用线程 interrupt() 方法来设置线程的中断标志为 true 并立即返回。设置标志仅仅是设置标志, 线程 B 实际并没有被中断,会继续往下执行。
  • boolean isInterrupted() 方法: 检测当前线程是否被中断。
  • boolean interrupted() 方法: 检测当前线程是否被中断,与 isInterrupted 不同的是,该方法如果发现当前线程被中断,则会清除中断标志。

说说线程有几种状态

状态 说明
NEW 当线程被创建后,如通过new Thread(),它处于新建状态。此时,线程已经被分配了必要的资源,但还没有开始执行。
RUNNABLE 当调用线程的start()方法后,线程进入可运行状态。在这个状态下,线程可能正在运行也可能正在等待获取 CPU 时间片,具体取决于线程调度器的调度策略。
BLOCKED 线程在试图获取一个锁以进入同步块/方法时,如果锁被其他线程持有,线程将进入阻塞状态,直到它获取到锁。
WAITING 线程进入等待状态是因为调用了如下方法之一:Object.wait()LockSupport.park()。在等待状态下,线程需要其他线程显式地唤醒,否则不会自动执行。
TIME_WAITING 当线程调用带有超时参数的方法时,如Thread.sleep(long millis)Object.wait(long timeout) 或LockSupport.parkNanos(),它将进入超时等待状态。线程在指定的等待时间过后会自动返回可运行状态。
TERMINATED 当线程的run()方法执行完毕后,或者因为一个未捕获的异常终止了执行,线程进入终止状态。一旦线程终止,它的生命周期结束,不能再被重新启动。

也就是说,线程的生命周期可以分为五个主要阶段:新建、可运行、运行中、阻塞/等待、和终止。线程在运行过程中会根据状态的变化在这些阶段之间切换。

什么是上下文切换?

为了让用户感觉多个线程是在同时执行的, CPU 资源的分配采用了时间片轮转也就是给每个线程分配一个时间片,线程在时间片内占用 CPU 执行任务。当线程使用完时间片后,就会处于就绪状态并让出 CPU 让其他线程占用,这就是上下文切换。

什么是守护线程?

Java 中的线程分为两类,分别为 daemon 线程(守护线程)和 user 线程(用户线程)。

在 JVM 启动时会调用 main 方法,main 方法所在的线程就是一个用户线程。其实在 JVM 内部同时还启动了很多守护线程, 比如垃圾回收线程。

那么守护线程和用户线程有什么区别呢?区别之一是当最后一个非守护线程束时, JVM 会正常退出,而不管当前是否存在守护线程,也就是说守护线程是否结束并不影响 JVM 退出。换而言之,只要有一个用户线程还没结束,正常情况下 JVM 就不会退出

线程间的通信方式有哪些?

线程之间传递信息有多种方式,比如说使用共享对象、wait() 和 notify() 方法、Exchanger 和 CompletableFuture。

①、使用共享对象,多个线程可以访问和修改同一个对象,从而实现信息的传递,比如说 volatile 和 synchronized 关键字。

关键字 volatile 用来修饰成员变量,告知程序任何对该变量的访问均需要从共享内存中获取,而对它的改变必须同步刷新回共享内存,保证所有线程对变量访问的可见性。

关键字 synchronized 可以修饰方法,或者以同步代码块的形式来使用,确保多个线程在同一个时刻,只能有一个线程在执行某个方法或某个代码块。

②、使用 wait() 和 notify(),例如,生产者-消费者模式中,生产者生产数据,消费者消费数据,通过 wait() 和 notify() 方法可以实现生产和消费的协调。

一个线程调用共享对象的 wait() 方法时,它会进入该对象的等待池,并释放已经持有的该对象的锁,进入等待状态。

一个线程调用共享对象的 notify() 方法时,它会唤醒在该对象等待池中等待的一个线程,使其进入锁池,等待获取锁。

Condition 也提供了类似的方法,await() 负责等待、signal() 和 signalAll() 负责通知。

通常与锁(特别是 ReentrantLock)一起使用,为线程提供了一种等待某个条件成真的机制,并允许其他线程在该条件变化时通知等待线程。更灵活、更强大。

③、使用 Exchanger,Exchanger 是一个同步点,可以在两个线程之间交换数据。一个线程调用 exchange() 方法,将数据传递给另一个线程,同时接收另一个线程的数据。

④、使用 CompletableFuture,CompletableFuture 是 Java 8 引入的一个类,支持异步编程,允许线程在完成计算后将结果传递给其他线程。

说说sleep和wait方法的区别

sleep 会让当前线程休眠,不涉及对象类,也不需要获取对象的锁,属于 Thread 类的方法;wait 会让获得对象锁的线程实现等待,要提前获得对象的锁,属于 Object 类的方法。

它们之间的区别主要有以下几点:

①、所属类不同

  • sleep() 方法专属于 Thread 类。
  • wait() 方法专属于 Object 类。

②、锁行为不同

当线程执行 sleep 方法时,它不会释放任何锁。也就是说,如果一个线程在持有某个对象的锁时调用了 sleep,它在睡眠期间仍然会持有这个锁。

③、使用条件不同

  • sleep() 方法可以在任何地方被调用。
  • wait() 方法必须在同步代码块或同步方法中被调用,这是因为调用 wait() 方法的前提是当前线程必须持有对象的锁。否则会抛出 IllegalMonitorStateException 异常。

④、唤醒方式不同

  • 调用 sleep 方法后,线程会进入 TIMED_WAITING 状态(定时等待状态),即在指定的时间内暂停执行。当指定的时间结束后,线程会自动恢复到 RUNNABLE 状态(就绪状态),等待 CPU 调度再次执行。
  • 调用 wait 方法后,线程会进入 WAITING 状态(无限期等待状态),直到有其他线程在同一对象上调用 notify 或 notifyAll,线程才会从 WAITING 状态转变为 RUNNABLE 状态,准备再次获得 CPU 的执行权。

⑤、抛出异常不同

  • sleep() 方法在等待期间,如果线程被中断,会抛出 InterruptedException
  • 如果线程被中断或等待时间到期时,wait() 方法同样会在等待期间抛出 InterruptedException

如何保证线程安全?

多线程安全是指在并发环境下,多个线程访问共享资源时,程序能够正确地执行,而不会出现数据不一致或竞争条件等问题。反之,如果程序出现了数据不一致、死锁、饥饿等问题,就称为线程不安全。

  • 为了保证线程安全,可以使用 synchronized 关键字或 ReentrantLock 来保证共享资源的互斥访问。
  • 对于简单的变量操作,可以使用 Atomic 类来实现无锁线程安全。
  • 可以使用线程安全容器,如 ConcurrentHashMap 或 CopyOnWriteArrayList。
  • 对于每个线程独立的数据,可以使用 ThreadLocal 来为每个线程提供独立的变量副本。
  • 对于简单的状态标志,可以使用 volatile 关键字确保多线程间的可见性。
有个int的变量为0,十个线程轮流对其进行++操作(循环10000次),结果是大于小于还是等于10万,为什么?

在这个场景中,最终的结果会小于 100000,原因在于多线程环境下,++ 操作不是一个原子操作,会出现线程安全问题。

int++ 实际上可以分解为三步:

  1. 读取变量的值。
  2. 将读取到的值加 1。
  3. 将结果写回变量。

多个线程在并发执行 ++ 操作时,可能出现以下竞态条件:

  • 线程 1 读取变量值为 0。
  • 线程 2 也读取变量值为 0。
  • 线程 1 进行加法运算并将结果 1 写回变量。
  • 线程 2 进行加法运算并将结果 1 写回变量,覆盖了线程 1 的结果。

可以通过 synchronized 或 AtomicInteger 实现线程安全。

有一个 key 对应的 value 是一个json 结构,json 当中有好几个子任务,这些子任务如果对 key 进行修改的话,会不会存在线程安全的问题?如何解决?如果是多个节点的情况,应该怎么加锁?

会。

在单节点环境中,可以使用 synchronized 关键字或 ReentrantLock 来保证对 key 的修改操作是原子的。

class KeyManager {
    private final ReentrantLock lock = new ReentrantLock();

    private String key = "{\"tasks\": [\"task1\", \"task2\"]}";

    public String readKey() {
        lock.lock();
        try {
            return key;
        } finally {
            lock.unlock();
        }
    }

    public void updateKey(String newKey) {
        lock.lock();
        try {
            this.key = newKey;
        } finally {
            lock.unlock();
        }
    }
}

在多节点环境中,可以使用分布式锁 Redisson 来保证对 key 的修改操作是原子的。

class DistributedKeyManager {
    private final RedissonClient redisson;

    public DistributedKeyManager() {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://127.0.0.1:6379");
        this.redisson = Redisson.create(config);
    }

    public void updateKey(String key, String newValue) {
        RLock lock = redisson.getLock(key);
        lock.lock();
        try {
            // 模拟读取和更新操作
            String currentValue = readFromDatabase(key); // 假设读取 JSON 数据
            String updatedValue = modifyJson(currentValue, newValue); // 修改 JSON
            writeToDatabase(key, updatedValue); // 写回数据库
        } finally {
            lock.unlock();
        }
    }

    private String readFromDatabase(String key) {
        // 模拟从数据库读取
        return "{\"tasks\": [\"task1\", \"task2\"]}";
    }

    private String modifyJson(String json, String newValue) {
        // 使用 JSON 库解析并修改
        return json.replace("task1", newValue);
    }

    private void writeToDatabase(String key, String value) {
        // 模拟写回数据库
    }
}
说一个线程安全的使用场景?

一个常见的使用场景是在实现单例模式时确保线程安全。

单例模式确保一个类只有一个实例,并提供一个全局访问点。在多线程环境下,如果多个线程同时尝试创建实例,单例类必须确保只创建一个实例。

饿汉式是一种比较直接的实现方式,它通过在类加载时就立即初始化单例对象来保证线程安全。

class Singleton {
    private static final Singleton instance = new Singleton();

    private Singleton() {
    }

    public static Singleton getInstance() {
        return instance;
    }
}

懒汉式单例则在第一次使用时初始化,这种方式需要使用双重检查锁定来确保线程安全,volatile 用来保证可见性,syncronized 用来保证同步。

public class LazySingleton {
    private static volatile LazySingleton instance;

    private LazySingleton() {}

    public static LazySingleton getInstance() {
        if (instance == null) { // 第一次检查
            synchronized (LazySingleton.class) {
                if (instance == null) { // 第二次检查
                    instance = new LazySingleton();
                }
            }
        }
        return instance;
    }
}

什么是ThreadLocal?

ThreadLocal 是 Java 中提供的一种用于实现线程局部变量的工具类。它允许每个线程都拥有自己的独立副本,从而实现线程隔离,用于解决多线程中共享对象的线程安全问题。

  • 在 Web 应用中,可以使用 ThreadLocal 存储用户会话信息,这样每个线程在处理用户请求时都能方便地访问当前用户的会话信息。
  • 在数据库操作中,可以使用 ThreadLocal 存储数据库连接对象,每个线程有自己独立的数据库连接,从而避免了多线程竞争同一数据库连接的问题。
  • 在格式化操作中,例如日期格式化,可以使用 ThreadLocal 存储 SimpleDateFormat 实例,避免多线程共享同一实例导致的线程安全问题。

使用 ThreadLocal 通常分为四步:

①、创建 ThreadLocal

//创建一个ThreadLocal变量
public static ThreadLocal localVariable = new ThreadLocal<>();

②、设置 ThreadLocal 的值

//设置ThreadLocal变量的值
localVariable.set("沉默王二是沙雕");

③、获取 ThreadLocal 的值

//获取ThreadLocal变量的值
String value = localVariable.get();

④、删除 ThreadLocal 的值

//删除ThreadLocal变量的值
localVariable.remove();

ThreadLocal的优缺点

①、线程隔离:每个线程访问的变量副本都是独立的,避免了共享变量引起的线程安全问题。由于 ThreadLocal 实现了变量的线程独占,使得变量不需要同步处理,因此能够避免资源竞争。

②、数据传递方便:ThreadLocal 常用于在跨方法、跨类时传递上下文数据(如用户信息等),而不需要在方法间传递参数。

除了 ThreadLocal,还有什么解决线程安全问题的方法?

①、Java 中的 synchronized 关键字可以用于方法和代码块,确保同一时间只有一个线程可以执行特定的代码段。

public synchronized void method() {
    // 线程安全的操作
}

②、Java 并发包(java.util.concurrent.locks)中提供了 Lock 接口和一些实现类,如 ReentrantLock。相比于 synchronized,ReentrantLock 提供了公平锁和非公平锁。

ReentrantLock lock = new ReentrantLock();

public void method() {
    lock.lock();
    try {
        // 线程安全的操作
    } finally {
        lock.unlock();
    }
}

③、Java 并发包还提供了一组原子变量类(如 AtomicInteger,AtomicLong 等),它们利用 CAS(比较并交换),实现了无锁的原子操作,适用于简单的计数器场景。

AtomicInteger atomicInteger = new AtomicInteger(0);

public void increment() {
    atomicInteger.incrementAndGet();
}

④、Java 并发包提供了一些线程安全的集合类,如 ConcurrentHashMap,CopyOnWriteArrayList 等。这些集合类内部实现了必要的同步策略,提供了更高效的并发访问。

ConcurrentHashMap map = new ConcurrentHashMap<>();

⑤、volatile 变量保证了变量的可见性,修改操作是立即同步到主存的,读操作从主存中读取。

private volatile boolean flag = false;

ThreadLocal是怎么实现的?

ThreadLocal 本身并不存储任何值,它只是作为一个映射,来映射线程的局部变量。当一个线程调用 ThreadLocal 的 set 或 get 方法时,实际上是访问线程自己的 ThreadLocal.ThreadLocalMap。ThreadLojup 是 ThreadLocal 的静态内部类,它内部维护了一个 Entry 数组,key 是 ThreadLocal 对象,value 是线程的局部变量本身。

ThreadLocal 的实现原理就是,每个线程维护一个 Map,key 为 ThreadLocal 对象,value 为想要实现线程隔离的对象。

1、当需要存线程隔离的对象时,通过 ThreadLocal 的 set 方法将对象存入 Map 中。

2、当需要取线程隔离的对象时,通过 ThreadLocal 的 get 方法从 Map 中取出对象。

3、Map 的大小由 ThreadLocal 对象的多少决定。

举例说明一些Threadlocal的应用场景

用来存储用户信息。很多其它场景的 cookie、session 等等数据隔离都可以通过 ThreadLocal 去实现。

数据库连接池也可以用 ThreadLocal,将数据库连接池的连接交给 ThreadLocal 进行管理,能够保证当前线程的操作都是同一个 Connnection。

ThreadLocal 内存泄露是怎么回事?

通常情况下,随着线程 Thread 的结束,其内部的 ThreadLocalMap 也会被回收,从而避免了内存泄漏。但如果一个线程一直在运行,并且其 ThreadLocalMap 中的 Entry.value 一直指向某个强引用对象,那么这个对象就不会被回收,从而导致内存泄漏。当 Entry 非常多时,可能就会引发更严重的内存溢出问题。

那怎么解决内存泄漏问题呢?​​​​​​​

很简单,使用完 ThreadLocal 后,及时调用 remove() 方法释放内存空间。

try {
    threadLocal.set(value);
    // 执行业务操作
} finally {
    threadLocal.remove(); // 确保能够执行清理
}

remove() 方法会将当前线程的 ThreadLocalMap 中的所有 key 为 null 的 Entry 全部清除,这样就能避免内存泄漏问题。

ThreadLocalMap 的底层实现机制

ThreadLocalMap 虽然被叫做 Map,其实它是没有实现 Map 接口的,但是结构还是和 HashMap 比较类似的,主要关注的是两个要素:元素数组散列方法

  • 元素数组

    一个 table 数组,存储 Entry 类型的元素,Entry 是 ThreaLocal 弱引用作为 key,Object 作为 value 的结构。

 private Entry[] table;
  • 散列方法

    散列方法就是怎么把对应的 key 映射到 table 数组的相应下标,ThreadLocalMap 用的是哈希取余法,取出 key 的 threadLocalHashCode,然后和 table 数组长度减一&运算(相当于取余)。

int i = key.threadLocalHashCode & (table.length - 1);

这里的 threadLocalHashCode 计算有点东西,每创建一个 ThreadLocal 对象,它就会新增0x61c88647,这个值很特殊,它是斐波那契数 也叫 黄金分割数hash增量为 这个数字,带来的好处就是 hash 分布非常均匀

    private static final int HASH_INCREMENT = 0x61c88647;

    private static int nextHashCode() {
        return nextHashCode.getAndAdd(HASH_INCREMENT);
    }

ThreadLocalMap 怎么解决 Hash 冲突的?

我们可能都知道 HashMap 使用了链表来解决冲突,也就是所谓的链地址法。

ThreadLocalMap 没有使用链表,自然也不是用链地址法来解决冲突了,它用的是另外一种方式——开放定址法。开放定址法是什么意思呢?简单来说,就是这个坑被人占了,那就接着去找空着的坑。

你可能感兴趣的:(面试,java,面试)