【2023Java面试八股文】Java多线程篇

目录

1.请你说说多线程

2.Java哪些地方使用了CAS

3.说说你对AQS(抽象队列同步器)的理解

4.说说怎么保证线程安全

5.说说你了解的线程同步方式

6.说说synchronize的用法及原理

7.synchronized和Lock有什么区别

8.说说Java中常用的锁及原理

9.说说volatile的用法及原理

10.说说线程的六个状态

11.说说你对ThreadLocal的理解

12.说说你了解的线程通信方式

13.说说线程的创建方式

14.说说你对线程池的理解

15.你知道哪些线程安全的集合?

16.HashMap是线程安全的吗?如果不是该如何解决?

17.请你说说JUC

18.请你说说ConcurrentHashMap


1.请你说说多线程

得分点

线程和进程的关系、为什么使用多线程

线程:线程是操作系统调度的最小单元,它可以让一个进程并发地处理多个任务。

关系:在一个进程里可以创建多个线程,这些线程都拥有各自的计数器、堆栈、局部变量,并且能够共享进程内的资源。由于共享资源,处理器便可以在这些线程之间快速切换,从而让使用者感觉这些线程在同时执行。

多线程的好处:当一个线程进入阻塞或者等待状态时,其他的线程可以获取CPU的执行权,提高了CPU的利用率。

多线程的缺点:可能产生死锁;频繁的上下文切换可能会造成资源的浪费;在并发编程中如果因为资源的限制,多线程串行执行,可能速度会比单线程更慢。

死锁:指多个线程在执行过程中,因争夺资源造成的一种相互等待的僵局。若无外力作用,它们都将无法推进下去。

详细:

所以,在一个进程里可以创建多个线程,这些线程都拥有各自的计数器、堆栈、局部变量,并且能够共享进程内的资源。由于共享资源,处理器便可以在这些线程之间快速切换,从而让使用者感觉这些线程在同时执行。

总的来说,操作系统可以同时执行多个任务,每个任务就是一个进程。进程可以同时执行多个任务,每个任务就是一个线程。一个程序运行之后至少有一个进程,而一个进程可以包含多个线程,但至少要包含一个线程。

使用多线程会给开发人员带来显著的好处,而使用多线程的原因主要有以下几点:

  • 1. 更多的CPU核心 现代计算机处理器性能的提升方式,已经从追求更高的主频向追求更多的核心发展,所以处理器的核心数量会越来越多,充分地利用处理器的核心则会显著地提高程序的性能。而程序使用多线程技术,就可以将计算逻辑分配到多个处理器核心上,显著减少程序的处理时间,从而随着更多处理器核心的加入而变得更有效率。
  • 2. 更快的响应时间 我们经常要针对复杂的业务编写出复杂的代码,如果使用多线程技术,就可以将数据一致性不强的操作派发给其他线程处理(也可以是消息队列),如上传图片、发送邮件、生成订单等。这样响应用户请求的线程就能够尽快地完成处理,大大地缩短了响应时间,从而提升了用户体验。
  • 3. 更好的编程模型 Java为多线程编程提供了良好且一致的编程模型,使开发人员能够更加专注于问题的解决,开发者只需为此问题建立合适的业务模型,而无需绞尽脑汁地考虑如何实现多线程。一旦开发人员建立好了业务模型,稍作修改就可以将其方便地映射到Java提供的多线程编程模型上。

2.Java哪些地方使用了CAS

得分点

原子类、AQS、并发容器

标准回答

CAS,compare and swap,解决多线程并行情况下使用锁造成性能损耗的一种机制。

CAS操作包含三个操作数——内存位置(V)、预期原值(A)和新值(B)。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。否则,处理器不做任何操作。

CAS有效地说明了“我认为位置V应该包含值A;如果包含该值,则将B放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。

 Java提供的API中使用CAS的地方有很多,比较典型的使用场景有原子类、AQS、并发容器

原子类 

对于原子类,它的内部提供了诸多原子操作的方法。如原子替换整数值、增加指定的值、加1,这些方法的底层便是采用操作系统提供的CAS原子指令来实现的。可以用于解决单个变量的线程安全问题

以AtomicInteger为例,某线程调用该对象的incrementAndGet()方式自增时采用CAS尝试修改它的值,若此时没有其他线程操作该值便修改成功否则反复执行CAS操作直到修改成功。

AQS(抽象队列同步器)

是一个多线程同步器的基础框架,许多组件都使用到了该框架,通过改变其中state变量的值来进行加锁解锁操作。多个线程竞争该锁时就是采用CAS的方式来修改这个状态码,修改成功则获得锁,失败则进入同步队列等待。

对于AQS,在向同步队列的尾部追加节点时,它首先会以CAS的方式尝试一次,如果失败则进入自旋状态,并反复以CAS的方式进行尝试。此外,在以共享方式释放同步状态时,它也是以CAS方式对同步状态进行修改的。

并发容器 

一些具有同步作用的容器,例如ConcurrentHashmap,它采用Synchronize+CAS+Node的方式实现同步。无论是数组的初始化,数组的扩容还是链表节点的操作都是先采CAS进行操作的。 

对于并发容器,以ConcurrentHashMap为例,它的内部多次使用了CAS操作。

在初始化数组时,它会以CAS的方式修改初始化状态,避免多个线程同时进行初始化。

在执行put方法初始化头节点时,它会以CAS的方式将初始化好的头节点设置到指定槽的首位,避免多个线程同时设置头节点。

在数组扩容时,每个线程会以CAS方式修改任务序列号来争抢扩容任务,避免和其他线程产生冲突。

在执行get方法时,它会以CAS的方式获取头指定槽的头节点,避免其他线程同时对头节点做出修改。

加分回答-原子操作指令

CAS的实现离不开操作系统原子指令的支持,Java中对原子指令封装的方法集中在Unsafe类中,包括:原子替换引用类型、原子替换int型整数、原子替换long型整数。这些方法都有四个参数:var1、var2、var4、var5,其中var1代表要操作的对象,var2代表要替换的成员变量,var4代表期望的值,var5代表更新的值。 public final native boolean compareAndSwapObject( Object var1, long var2, Object var4, Object var5); public final native boolean compareAndSwapInt( Object var1, long var2, int var4, int var5); public final native boolean compareAndSwapLong( Object var1, long var2, long var4, long var6);

3.说说你对AQS(抽象队列同步器)的理解

得分点

模板方法、同步队列、同步状态

标准回答

底层 

AQS是一个多线程同步器的基础框架,许多组件都使用到了该框架,通过改变其中state变量的值来进行加锁解锁操作。多个线程竞争该锁时就是采用CAS的方式来修改这个状态码,修改成功则获得锁,失败则进入同步队列等待。 

1、概念:AQS是抽象队列同步器,用来构建锁的基础框架,Lock实现类都是基于AQS实现的

2、模板方法:AQS是基于模板方法模式进行设计的,所以锁的实现需要继承AQS并重写它指定的方法。

3、同步队列、同步状态:AQS内部定义了一个FIFO的队列来实现线程的同步,同时还定义了同步状态来记录锁的信息。

AQS的模板方法,将管理同步状态的逻辑提炼出来形成标准流程,这些方法主要包括:独占式获取同步状态、独占式释放同步状态、共享式获取同步状态、共享式释放同步状态。

模板方法:AQS是基于模板方法模式进行设计的,所以锁的实现需要继承AQS并重写它指定的方法。

AQS内部定义了一个FIFO的队列来实现线程的同步,同时还定义了同步状态来记录锁的信息

AQS的模板方法,将管理同步状态的逻辑提炼出来形成标准流程,这些方法主要包括:独占式获取同步状态、独占式释放同步状态、共享式获取同步状态、共享式释放同步状态。

以独占式获取同步状态为例,它的大致流程是:

 1. 尝试以独占方式获取同步状态。

 2. 如果状态获取失败,则将当前线程加入同步队列。

 3. 自旋处理同步状态,如果当前线程位于队头,则唤醒它并让它出队,否则使其进入阻塞状态。 其中,有些步骤无法在父类确定,则提炼成空方法留待子类实现。例如,第一步的尝试操作,对于公平锁和非公平锁来说就不一样,所以子类在实现时需要按照场景各自实现这个方法。

同步队列:AQS的同步队列,是一个双向链表,AQS则持有链表的头尾节点。对于尾节点的设置,是存在多线程竞争的,所以采用CAS的方式进行修改。对于头节点设置,则一定是拿到了同步状态的线程才能处理,所以修改头节点不需要采用CAS的方式。

同步状态:AQS的同步状态,是一个int类型的整数,它在表示状态的同时还能表示数量。通常情况下,状态为0时表示无锁,状态大于0时表示锁的重入次数。另外,在读写锁的场景中,这个状态标志既要记录读锁又要记录写锁。于是,锁的实现者就将状态表示拆成高低两部分,高位存读锁、低位存写锁。 加分回答 同步状态需要在并发环境下修改,所以需要保证其线程安全。由于AQS本身就是锁的实现工具,所以不适合用锁来保证其线程安全,因为如果你用一个锁来定义另一个锁的话,那干脆直接用synchronized算了。实际上,同步状态是被volatile修饰的,该关键字可以保证状态变量的内存可见性,从而解决了线程安全问题。 加分回答 同步状态需要在并发环境下修改,所以需要保证其线程安全。由于AQS本身就是锁的实现工具,所以不适合用锁来保证其线程安全,因为如果你用一个锁来定义另一个锁的话,那干脆直接用synchronized算了。实际上,同步状态是被volatile修饰的,该关键字可以保证状态变量的内存可见性,从而解决了线程安全问题。

4.说说怎么保证线程安全

得分点

原子类、volatile、锁

标准回答

线程安全问题:指在多线程背景下,线程没有按照我们的预期执行,导致操作共享变量出现异常

按照资源占用情况由轻到重排列,通过原子类、volatile、锁保证线程安全。 

原子类和volatile只能保证单个共享变量的线程安全,锁则可以保证临界区内的多个共享变量线程安全。

原子类

原子类是具有原子操作特征(化学中原子是最小单位、不可分割)的类,原子是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。。

java.util.concurrent.atomic包下,有一系列“Atomic”开头的类,统称为原子类。例如AtomicInteger替代int底层采用CAS原子指令实现,内部的存储值使用volatile修饰,因此多线程之间是修改可见的

以AtomicInteger为例,某线程调用该对象的incrementAndGet()方式自增时采用CAS尝试修改它的值,若此时没有其他线程操作该值便修改成功否则反复执行CAS操作直到修改成功。

volatile

volatile关键字是轻量级的同步机制,用来保证有序性和可见性的。有序性:被volatile声明的变量之前的代码一定会比它先执行,而之后的代码一定会比它慢执行。可见性:一旦修改变量则立即刷新到共享内存中,当其他线程要读取这个变量的时候,最终会去内存中读取,而不是从自己的工作空间中读取。

加锁的方式有两种,分别是synchronized关键字和Lock接口(在JUC包下)。

synchronized锁是互斥锁,可以作用于实例方法、静态方法、代码块,能够保证同一个时刻只有一个线程执行该段代码,保证线程安全。 在执行完或者出现异常时自动释放锁。synchronized锁基于对象头和Monitor对象,在1.6之后引入轻量级锁、偏向锁等优化。  

lock锁接口可以通过lock、unlock方法锁住一段代码,Lock实现类都是基于AQS实现的Lock可以让等待锁的线程响应中断,而synchronized却不行,使用synchronized时,等待的线程会一直等待下去,不能够响应中断。

线程安全:在拥有共享数据的多条线程并行执行的程序中,线程安全的代码会通过同步机制保证各个线程都可以正常且正确的执行,不会出现数据污染等意外情况。 

Java保证线程安全的方式有很多,其中较为常用的有三种,按照资源占用情况由轻到重排列,这三种保证线程安全的方式分别是原子类、volatile、锁。

JDK从1.5开始提供了java.util.concurrent.atomic包,这个包中的原子操作类提供了一种用法简单、性能高效、线程安全地更新一个变量的方式。

在atomic包里一共提供了17个类,按功能可以归纳为4种类型的原子更新方式,分别是原子更新基本类型、原子更新引用类型、原子更新属性、原子更新数组。无论原子更新哪种类型,都要遵循“比较和替换”规则,即比较要更新的值是否等于期望值,如果是则更新,如果不是则失败。 volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的“可见性”,从而可以保证单个变量读写时的线程安全。可见性问题是由处理器核心的缓存导致的,每个核心均有各自的缓存,而这些缓存均要与内存进行同步。volatile具有如下的内存语义:当写一个volatile变量时,该线程本地内存中的共享变量的值会被立刻刷新到主内存;当读一个volatile变量时,该线程本地内存会被置为无效,迫使线程直接从主内存中读取共享变量。 原子类和volatile只能保证单个共享变量的线程安全,锁则可以保证临界区内的多个共享变量的线程安全,Java中加锁的方式有两种,分别是synchronized关键字和Lock接口。synchronized是比较早期的API,在设计之初没有考虑到超时机制、非阻塞形式,以及多个条件变量。若想通过升级的方式让它支持这些相对复杂的功能,则需要大改它的语法结构,不利于兼容旧代码。因此,JDK的开发团队在1.5新增了Lock接口,并通过Lock支持了上述的功能,即:支持响应中断、支持超时机制、支持以非阻塞的方式获取锁、支持多个条件变量(阻塞队列)。

加分回答

实现线程安全的方式有很多,除了上述三种方式之外,还有如下几种方式:

  • 1. 无状态设计 线程安全问题是由多线程并发修改共享变量引起的,如果在并发环境中没有设计共享变量,则自然就不会出现线程安全问题了。这种代码实现可以称作“无状态实现”,所谓状态就是指共享变量。
  • 2. 不可变设计 如果在并发环境中不得不设计共享变量,则应该优先考虑共享变量是否为只读的,如果是只读场景就可以将共享变量设计为不可变的,这样自然也不会出现线程安全问题了。具体来说,就是在变量前加final修饰符,使其不可被修改,如果变量是引用类型,则将其设计为不可变类型(参考String类)。
  • 3. 并发工具 java.util.concurrent包提供了几个有用的并发工具类,一样可以保证线程安全: - Semaphore:就是信号量,可以控制同时访问特定资源的线程数量。 - CountDownLatch:允许一个或多个线程等待其他线程完成操作。 - CyclicBarrier:让一组线程到达一个屏障时被阻塞,直到最后一个线程到达屏障时,屏障才会打开,所有被屏障拦截的线程才会继续运行。
  • 4. 本地存储 我们也可以考虑使用ThreadLocal存储变量,ThreadLocal可以很方便地为每一个线程单独存一份数据,也就是将需要并发访问的资源复制成多份。这样一来,就可以避免多线程访问共享变量了,它们访问的是自己独占的资源,它从根本上隔离了多个线程之间的数据共享。 

5.说说你了解的线程同步方式

得分点

synchronized、Lock

标准回答

线程同步:即当有一个线程在对内存进行操作时,其他线程都不可以对这个内存地址进行操作,直到该线程完成操作, 其他线程才能对该内存地址进行操作,而其他线程又处于等待状态。

Java主要通过加锁的方式实现线程同步,而锁有两类,分别是synchronized关键字和Lock接口(在JUC包下)。

synchronized锁是互斥锁,可以作用于实例方法、静态方法、代码块,能够保证同一个时刻只有一个线程执行该段代码,保证线程安全。 在执行完或者出现异常时自动释放锁。synchronized锁基于对象头和Monitor对象,在1.6之后引入轻量级锁、偏向锁等优化。  

lock锁接口可以通过lock、unlock方法锁住一段代码,Lock实现类都是基于AQS实现的。Lock可以让等待锁的线程响应中断,而synchronized却不行,使用synchronized时,等待的线程会一直等待下去,不能够响应中断。

synchronized可以加在三个不同的位置,对应三种不同的使用方式,这三种方式的区别是锁对象不同:

1. 加在普通方法上,则锁是当前的实例(this)。

2. 加在静态方法上,则锁是当前类的Class对象。

3. 加在代码块上,则需要在关键字后面的小括号里,显式指定一个对象作为锁对象。

不同的锁对象,意味着不同的锁粒度,所以我们不应该无脑地将它加在方法前了事,尽管通常这可以解决问题。而是应该根据要锁定的范围,准确的选择锁对象,从而准确地确定锁的粒度,降低锁带来的性能开销。

synchronized是比较早期的API,在设计之初没有考虑到超时机制、非阻塞形式,以及多个条件变量。若想通过升级的方式让synchronized支持这些相对复杂的功能,则需要大改它的语法结构,不利于兼容旧代码。因此,JDK的开发团队在1.5引入了Lock接口,并通过Lock支持了上述的功能。

Lock支持的功能包括:支持响应中断、支持超时机制、支持以非阻塞的方式获取锁、支持多个条件变量(阻塞队列)。

加分回答

synchronized采用“CAS+Mark Word”实现,为了性能的考虑,并通过锁升级机制降低锁的开销。在并发环境中,synchronized会随着多线程竞争的加剧,按照如下步骤逐步升级:无锁、偏向锁、轻量级锁、重量级锁。

Lock则采用“CAS+volatile”实现,其实现的核心是AQS。AQS是线程同步器,是一个线程同步的基础框架,它基于模板方法模式。在具体的Lock实例中,锁的实现是通过继承AQS来实现的,并且可以根据锁的使用场景,派生出公平锁、不公平锁、读锁、写锁等具体的实现。 

6.说说synchronize的用法及原理

得分点

作用于三个位置、对象头、锁升级

标准回答 

synchronized锁: 

synchronized锁是互斥锁,可以作用于实例方法、静态方法、代码块,能够保证同一个时刻只有一个线程执行该段代码,保证线程安全。 在执行完或者出现异常时自动释放锁。synchronized锁基于对象头和Monitor对象,在1.6之后引入轻量级锁、偏向锁等优化。  

作用于三个位置:

 1. 作用在静态方法上,则锁是当前类的Class对象。

 2. 作用在普通方法上,则锁是当前的实例(this)。

 3. 作用在代码块上,则需要在关键字后面的小括号里,显式指定一个对象作为锁对象。

对象头: synchronized的底层是采用Java对象头来存储锁信息的,并且还支持锁升级。

锁升级:当竞争小的时候,只需以较小的代价加锁,直到竞争加剧,才使用重量级锁,从而减小了加锁带来的开销。

synchronized可以作用在三个不同的位置,对应三种不同的使用方式,这三种方式的区别是锁对象不同。不同的锁对象,意味着不同的锁粒度,所以我们不应该无脑地将它加在方法前了事,尽管通常这可以解决问题。而是应该根据要锁定的范围,准确的选择锁对象,从而准确地确定锁的粒度,降低锁带来的性能开销。

对象头

synchronized的底层是采用Java对象头来存储锁信息的,并且还支持锁升级

Java对象头包含三部分,分别是Mark Word、Class Metadata Address、Array length。其中,Mark Word用来存储对象的hashCode及锁信息,Class Metadata Address用来存储对象类型的指针,而Array length则用来存储数组对象的长度。如果对象不是数组类型,则没有Array length信息。

synchronized的锁信息包括锁的标志和锁的状态,这些信息都存放在对象头的Mark Word这一部分。

Java 6为了减少获取锁和释放锁带来的性能消耗,引入了偏向锁和轻量级锁

所以,在Java 6中,锁一共被分为4种状态,级别由低到高依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。随着线程竞争情况的升级,锁的状态会从无锁状态逐步升级到重量级锁状态。锁可以升级却不能降级,这种只能升不能降的策略,是为了提高效率。

synchronized的早期设计并不包含锁升级机制,所以性能较差,那个时候只有无锁和有锁之分。是为了提升性能才引入了偏向锁和轻量级锁,所以需要重点关注这两种状态的原理,以及它们的区别。

偏向锁:

偏向锁,顾名思义就是锁偏向于某一个线程

当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程再进入和退出同步块时就不需要做加锁和解锁操作了,只需要简单地测试一下Mark Word里是否存储着自己的线程ID即可。 轻量级锁,就是加锁时JVM先在当前线程栈帧中创建用于存储锁记录的空间,并将Mark Word复制到锁记录中,官方称之为Displaced Mark Word。然后线程尝试以CAS方式将Mark Word替换为指向锁记录的指针,如果成功则当前线程获得锁,如果失败则表示其他线程竞争锁,此时当前线程就会通过自旋来尝试获取锁。

加分回答-锁升级的过程

下面,我们再从实际场景出发,来具体说说锁升级的过程: 

1. 开始,没有任何线程访问同步块,此时同步块处于无锁状态。

 2. 然后,线程1首先访问同步块,它以CAS(比较和交换)的方式修改Mark Word,尝试加偏向锁。由于此时没有竞争,所以偏向锁加锁成功,此时Mark Word里存储的是线程1的ID。

 3. 然后,线程2开始访问同步块,它以CAS的方式修改Mark Word,尝试加偏向锁。由于此时存在竞争,所以偏向锁加锁失败,于是线程2会发起撤销偏向锁的流程(清空线程1的ID),于是同步块从偏向线程1的状态恢复到了可以公平竞争的状态。

 4. 然后,线程1和线程2共同竞争,它们同时以CAS方式修改Mark Word,尝试加轻量级锁。由于存在竞争,只有一个线程会成功,假设线程1成功了。但线程2不会轻易放弃,它认为线程1很快就能执行完毕,执行权很快会落到自己头上,于是线程2继续自旋加锁。

 5. 最后,如果线程1很快执行完,则线程2就会加轻量级锁成功,锁不会晋升到重量级状态。也可能是线程1执行时间较长,那么线程2自旋一定次数后就放弃自旋,并发起锁膨胀的流程。届时,锁被线程2修改为重量级锁,之后线程2进入阻塞状态。而线程1重复加锁或者解锁时,CAS操作都会失败,此时它就会释放锁并唤醒等待的线程。

总之,在锁升级的机制下,锁不会一步到位变为重量级锁,而是根据竞争的情况逐步升级的。当竞争小的时候,只需以较小的代价加锁,直到竞争加剧,才使用重量级锁,从而减小了加锁带来的开销。

7.synchronized和Lock有什么区别

得分点

使用方式、主要特性、实现机制

标准回答

Lock和synchronized有以下几点不同:

  1)接口和关键字。Lock是一个接口,而synchronized是Java中的关键字,synchronized是内置的语言实现;

  2)死锁问题。synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而Lock在发生异常时,如果没有主动通过unLock()去释放锁,则很可能造成死锁现象,因此使用Lock时需要在finally块中释放锁;

  3)让等待锁的线程响应中断。Lock可以让等待锁的线程响应中断,而synchronized却不行,使用synchronized时,等待的线程会一直等待下去,不能够响应中断;

  4)得知是否成功获取锁。通过Lock可以知道有没有成功获取锁,而synchronized却无法办到。

  5)效率对比。Lock可以提高多个线程进行读操作的效率。在性能上来说,如果竞争资源不激烈,两者的性能是差不多的,而当竞争资源非常激烈时(即有大量线程同时竞争),此时Lock的性能要远远优于synchronized。所以说,在具体使用时要根据适当情况选择。

synchronized和Lock都是锁,都是线程同步的手段,它们的区别主要体现在如下三个方面:

1. 使用方式的区别 synchronized关键字可以作用在静态方法、实例方法和代码块上,它是一种隐式锁,即我们无需显式地获取和释放锁,所以使用起来十分的方便。在这种同步方式下,我们需要依赖Monitor(同步监视器)来实现线程通信。若关键字作用在静态方法上,则Monitor就是当前类的Class对象;若关键字作用在实例方法上,则Monitor就是当前实例(this);若关键字作用在代码块上,则需要在关键字后面的小括号里显式指定一个对象作为Monitor。 Lock接口是显式锁,即我们需要调用其内部定义的方法显式地加锁和解锁,相对于synchronized来说这显得有些繁琐,但是却提供了更大的灵活性。在这种同步方式下,我们需要依赖Condition对象来实现线程通信,该对象是由Lock对象创建出来的,依赖于Lock。每个Condition代表一个等待队列,而一个Lock可以创建多个Condition对象。相对而言,每个Monitor也代表一个等待队列,但synchronized只能有一个Monitor。所以,在实现线程通信方面,Lock接口具备更大的灵活性。

2. 功能特性的区别 synchronized是早期的API,Lock则是在JDK 1.5时引入的。在设计上,Lock弥补了synchronized的不足,它新增了一些特性,均是synchronized不具备的,这些特性包括: - 可中断地获取锁:线程在获取锁的过程中可以被中断。 - 非阻塞地获取锁:该方法在调用后立刻返回,若能取到锁则返回true,否则返回false。 - 可超时地获取锁:若线程在到达超时时间后仍未获得锁,并且线程也没有被中断,则返回false。

3. 实现机制的区别 synchronized的底层是采用Java对象头来存储锁信息的,对象头包含三部分,分别是Mark Word、Class Metadata Address、Array length。其中,Mark Word用来存储对象的hashCode及锁信息,Class Metadata Address用来存储对象类型的指针,而Array length则用来存储数组对象的长度。 AQS是队列同步器,是用来构建锁的基础框架,Lock实现类都是基于AQS实现的。AQS是基于模板方法模式进行设计的,所以锁的实现需要继承AQS并重写它指定的方法。AQS内部定义了一个FIFO的队列来实现线程的同步,同时还定义了同步状态来记录锁的信息。 加分回答 早期的synchronized性能较差,不如Lock。后来synchronized在实现上引入了锁升级机制,性能上已经不输给Lock了。所以,synchronized和Lock的区别主要不在性能上,因为二者性能相差无几。 Java 6为了减少获取锁和释放锁带来的性能消耗,引入了偏向锁和轻量级锁。所以,从Java 6开始,锁一共被分为4种状态,级别由低到高依次是:无锁、偏向锁、轻量级锁、重量级锁。随着线程竞争情况的升级,锁的状态会从无锁状态逐步升级到重量级锁状态。锁可以升级却不能降级,这种只能升不能降的策略,是为了提高效率。 synchronized的早期设计并不包含锁升级机制,那个时候只有无锁和有锁之分。是为了提升性能才引入了偏向锁和轻量级锁,所以需要重点关注这两种状态的原理,以及它们的区别。 偏向锁,顾名思义就是锁偏向于某一个线程。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程再进入和退出同步块时就不需要做加锁和解锁操作了,只需要简单地测试一下Mark Word里是否存储着自己的线程ID即可。 轻量级锁,就是加锁时JVM先在当前线程栈帧中创建用于存储锁记录的空间,并将Mark Word复制到锁记录中,官方称之为Displaced Mark Word。然后线程尝试以CAS方式将Mark Word替换为指向锁记录的指针,如果成功则当前线程获得锁,如果失败则表示其他线程竞争锁,此时当前线程就会通过自旋来尝试获取锁。

8.说说Java中常用的锁及原理

得分点

对象头、AQS

标准回答

Java中常用的锁:synchronized关键字和lock锁接口。

synchronized关键字底层采用java对象头来存储锁信息的。

lock锁接口是基于AQS(抽象队列同步器)实现的。AQS内部定义一个先进先出的队列实现锁的同步,同时还定义了同步状态来记录锁信息。

AQS通过改变其中state变量的值来进行加锁解锁操作。多个线程竞争该锁时就是采用CAS的方式来修改这个状态码,修改成功则获得锁,失败则进入同步队列等待。 

Java中加锁有两种方式,分别是synchronized关键字和Lock接口,而Lock接口的经典实现是ReentrantLock。另外还有ReadWriteLock接口,它的内部设计了两把锁分别用于读写,这两把锁都是Lock类型,它的经典实现是ReentrantReadWriteLock。其中,synchronized的实现依赖于对象头,Lock接口的实现则依赖于AQS。

synchronized

synchronized的底层是采用Java对象头来存储锁信息的,对象头包含三部分,分别是Mark Word、Class Metadata Address、Array length。其中,Mark Word用来存储对象的hashCode及锁信息,Class Metadata Address用来存储对象类型的指针,而Array length则用来存储数组对象的长度。

Lock

AQS是队列同步器,是用来构建锁的基础框架,Lock实现类都是基于AQS实现的。AQS是基于模板方法模式进行设计的,所以锁的实现需要继承AQS并重写它指定的方法。AQS内部定义了一个FIFO的队列来实现线程的同步,同时还定义了同步状态来记录锁的信息。

加分回答

ReentrantLock通过内部类Sync定义了锁,它还定义了Sync的两个子类FrSync和NonfrSync,这两个子类分别代表公平锁和非公平锁。Sync继承自AQS,它不仅仅使用AQS的同步状态记录锁的信息,还利用同步状态记录了重入次数。同步状态是一个整数,当它为0时代表无锁,当它为N时则代表线程持有锁并重入了N次。 ReentrantReadWriteLock支持重入的方式与ReentrantLock一致,它也定义了内部类Sync,并定义出两个子类FrSync和NonfrSync来实现公平锁和非公平锁。此外,ReentrantReadWriteLock内部包含读和写两把锁,这两把锁都是由Sync来实现的。区别在于读锁支持共享,即多个线程可以同时加读锁成功,而写锁是互斥的,即只能有一个线程加锁成功。

9.说说volatile的用法及原理

得分点

特性、内存语义、实现机制

特性:

有序性:被volatile声明的变量之前的代码一定会比它先执行,而之后的代码一定会比它慢执行。

可见性:一旦修改变量则立即刷新到共享内存中,当其他线程要读取这个变量的时候,最终会去内存中读取,而不是从自己的工作空间中读取。 

原子性:对单个volatile变量的读写具有原子性,对“volatile变量++”这种复合操作则不具有原子性。

注意:volatile的原子性只针对单个变量,复合操作不能保证原子性,这也是和synchronized的区别。 

内存语义:

- 写内存语义:当写一个volatile变量时,JMM(Java内存模型)会把该线程本地内存中的共享变量的值刷新到主内存中。

- 读内存语义:当读一个volatile变量时,JMM会把该线程本地内存置为无效,使其从主内存中读取共享变量。

底层实现机制:

volatile的底层是采用内存屏障来实现的,就是在编译器生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序

volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的“可见性”。可见性的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。如果volatile使用恰当的话,它比synchronized的执行成本更低,因为它不会引起线程上下文的切换和调度。简而言之,volatile变量具有以下特性:

- 可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。

- 原子性:对单个volatile变量的读写具有原子性,对“volatile变量++”这种复合操作则不具有原子性。

内存语义:

volatile通过影响线程的内存可见性来实现上述特性,它具备如下的内存语义。其中,JMM是指Java内存模型,而本地内存只是JMM的一个抽象概念,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。在本文中,大家可以将其简单理解为缓存。

- 写内存语义:当写一个volatile变量时,JMM会把该线程本地内存中的共享变量的值刷新到主内存中。

- 读内存语义:当读一个volatile变量时,JMM会把该线程本地内存置为无效,使其从主内存中读取共享变量。

底层实现机制:

volatile的底层是采用内存屏障来实现的,就是在编译器生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。内存屏障就是一段与平台相关的代码,Java中的内存屏障代码都在Unsafe类中定义,共包含三个方法:LoadFence()、storeFence()、fullFence()。 加分回答 从内存语义的角度来说,volatile的读/写,与锁的获取/释放具有相同的内存效果。即volatile读与锁的获取有相同的内存语义,volatile写与锁的释放有相同的内存语义。 volatile只能保证单个变量读写的原子性,而锁则可以保证对整个临界区的代码执行具有原子性。所以,在功能上锁比volatile更强大,在可伸缩性和性能上volatile更优优势。

10.说说线程的六个状态

得分点

NEW、RUNNABLE、BLOCKED、WTING、TIMED_WTING、TERMINATED

标准回答

Java线程在运行的生命周期中,在任意给定的时刻,只能处于下列6种状态之一:

NEW :初始状态,线程被创建,但是还没有调用start方法。

RUNNABLE:可运行状态,就绪或运行。线程正在JVM中执行,但是有可能在等待操作系统的调度。

BLOCKED :阻塞状态,线程正在等待获取监视器锁。

WTING :等待状态,线程正在等待其他线程的通知或中断。

TIMED_WTING:超时等待状态,在WTING的基础上增加了超时时间,即超出时间自动返回。

TERMINATED:终止状态,线程已经执行完毕。

线程在创建之后默认为初始状态,在调用start方法之后进入可运行状态,可运行状态不代表线程正在运行,它有可能正在等待操作系统的调度。进入等待状态的线程需要其他线程的通知才能返回到可运行状态,而超时等待状态相当于在等待状态的基础上增加了超时限制,除了他线程的唤醒,在超时时间到达时也会返回运行状态。

此外,线程在执行同步方法时,在没有获取到锁的情况下,会进入到阻塞状态。线程在执行完run方法之后,会进入到终止状态。

加分回答

Java将操作系统中的就绪和运行两个状态合并为可运行状态(RUNNABLE)。线程阻塞于synchronized的监视器锁时会进入阻塞状态,而线程阻塞于Lock锁时进入的却是等待状态,这是因为Lock接口实现类对于阻塞的实现均使用了LockSupport类中的相关方法。

11.说说你对ThreadLocal的理解

得分点

作用、实现机制

标准回答

作用:ThreadLocal,即线程变量,它将需要并发访问的资源复制多份,让每个线程拥有一份资源。由于每个线程都拥有自己的资源副本,从而也就没有必要对该变量进行同步了。在编写多线程代码时,可以把不安全的变量封装进ThreadLocal

内存泄漏问题:使用线程池的时候 ThreadLocal 需要在使用完线程中的线程变量手动 remove,因为线程是在线程池中没有被销毁,ThreadLocal中的对象不能被自动垃圾回收 

实现机制:

在实现上,Thread类中声明了threadLocals变量,用于存放当前线程独占的资源。ThreadLocal类中定义了该变量的类型(ThreadLocalMap),这是一个类似于Map的结构,用于存放键值对。

ThreadLocal类中还提供了set和get方法,set方法会初始化ThreadLocalMap并将其绑定到Thread.threadLocals,从而将传入的值绑定到当前线程。在数据存储上,传入的值将作为键值对的value,而key则是ThreadLocal对象本身(this)。get方法没有任何参数,它会以当前ThreadLocal对象(this)为key,从Thread.threadLocals中获取与当前线程绑定的数据。

加分回答-比较ThreadLocal和同步机制

注意,ThreadLocal不能替代同步机制,两者面向的问题领域不同。 

同步机制是为了同步多个线程对相同资源的并发访问,是多个线程之间进行通信的有效方式。当有一个线程在对内存进行操作时,其他线程都不可以对这个内存地址进行操作。

ThreadLocal是为了隔离多个线程的数据共享,从根本上避免多个线程之间对共享资源(变量)的竞争,也就不需要对多个线程进行同步了。

一般情况下,如果多个线程之间需要共享资源,以达到线程之间的通信功能,就使用同步机制。如果仅仅需要隔离多个线程之间的共享冲突,则可以使用ThreadLocal。

使用ThreadLocal线程隔离示例:

两个线程分表获取了自己线程存放的变量,他们之间变量的获取并不会错乱:

public class ThreadLocaDemo {
 
    private static ThreadLocal<String> localVar = new ThreadLocal<String>();
 
    static void print(String str) {
        //打印当前线程中本地内存中本地变量的值
        System.out.println(str + " :" + localVar.get());
        //清除本地内存中的本地变量
        localVar.remove();
    }
    public static void main(String[] args) throws InterruptedException {
 
        new Thread(new Runnable() {
            public void run() {
                ThreadLocaDemo.localVar.set("local_A");
                print("A");
                //打印本地变量
                System.out.println("after remove : " + localVar.get());
               
            }
        },"A").start();
 
        Thread.sleep(1000);
 
        new Thread(new Runnable() {
            public void run() {
                ThreadLocaDemo.localVar.set("local_B");
                print("B");
                System.out.println("after remove : " + localVar.get());
              
            }
        },"B").start();
    }
}
 
A :local_A
after remove : null
B :local_B
after remove : null
 

12.说说你了解的线程通信方式

得分点

Monitor、Condition

标准回答

线程通信:多个线程在并发执行的时候,他们在CPU中是随机切换执行的,这个时候我们想多个线程一起来完成一件任务,这个时候我们就需要线程之间的通信了,多个线程一起来完成一个任务。

线程通信方式: Monitor(同步监视器)和Condition。

线程通信方式取决于线程同步方式。

当我们使用synchronize同步时就会使用monitor来实现线程通信,这里的monitor其实就是锁对象,其利用object的wait,notify,notifyAll等方法来实现线程通信。

而使用Lock进行同步时就是使用Condition来实现线程通信,Condition对象通过Lock创建出来依赖于Lock对象,使用其await,sign或signAll方法实现线程通信。 

在Java中,常用的线程通信方式有两种,分别是利用Monitor实现线程通信、利用Condition实现线程通信。

线程同步是线程通信的前提,所以究竟采用哪种方式实现通信,取决于线程同步的方式。

Monitor

如果是采用synchronized关键字进行同步,则需要依赖Monitor(同步监视器)

实现线程通信,Monitor就是锁对象。在synchronized同步模式下,锁对象可以是任意的类型,所以通信方法自然就被定义在Object类中了,这些方法包括:wt()、notify()、notifyAll()。

一个线程通过Monitor调用wt()时,它就会释放锁并在此等待。当其他线程通过Monitor调用notify()时,则会唤醒在此等待的一个线程。当其他线程通过Monitor调用notifyAll()时,则会唤醒在此等待的所有线程。

Lock

JDK 1.5新增了Lock接口及其实现类,提供了更为灵活的同步方式。

如果是采用Lock对象进行同步,则需要依赖Condition实现线程通信,Condition对象是由Lock对象创建出来的,它依赖于Lock对象。

Condition对象中定义的通信方法,与Object类中的通信方法类似,它包括awt()、signal()、signalAll()。通过名字就能看出它们的含义了,当通过Condition调用awt()时当前线程释放锁并等待,当通过Condition调用signal()时唤醒一个等待的线程,当通过Condition调用signalAll()时则唤醒所有等待的线程。

加分回答

线程同步是基于同步队列实现的,而线程通信是基于等待队列实现的。当调用等待方法时,即将当前线程加入等待队列。当调用通知方法时,即将等待队列中的一个或多个线程转移回同步队列。因为synchronized只有一个Monitor,所以它就只有一个等待队列。而Lock对象可以创建出多个Condition,所以它拥有多个等待队列。多个等待队列带来了极大的灵活性,所以基于Condition的通信方式更为推荐。 比如,在实现生产消费模型时,生产者要通知消费者、消费者要通知生产者。相反,不应该出现生产者通知生产者、消费者通知消费者这样的情况。如果使用synchronized实现这个模型,由于它只有一个等待队列,所以只能把生产者和消费者加入同一个队列,这就会导致生产者通知生产者、消费者通知消费者的情况出现。采用Lock实现这个模型时,由于它有多个等待队列,可以有效地将这两个角色区分开,就能避免出现这样的问题。

13.说说线程的创建方式

得分点

Thread、Runnable、Callable

标准回答

创建线程有3种方式:

1.继承Thread类,重写run()方法;

2.实现Runnable接口(推荐),并实现该接口的run()方法;

3.实现Callable接口,重写call()方法。

前两种方式线程执行完后都没有返回值,最后一种带返回值。

推荐实现Runnable接口的方式,因为Java是单继承,线程类实现接口的同时,还可以继承于其他类

创建线程有三种方式,分别是继承Thread类、实现Runnable接口、实现Callable接口。

1. 通过继承Thread类来创建线程的步骤如下

  • - 定义Thread类的子类,并重写该类的run()方法,该方法将作为线程执行体。
  • - 创建Thread子类的实例,即创建了线程对象。
  • - 调用线程对象的start()方法来启动该线程。

2. 通过实现Runnable接口来创建线程的步骤如下

  • - 定义Runnable接口的实现类,并实现该接口的run()方法,该方法将作为线程执行体。
  • - 创建Runnable实现类的实例,并将其作为参数来创建Thread对象,Thread对象为线程对象。
  • - 调用线程对象的start()方法来启动该线程。

3. 通过实现Callable接口来创建线程的步骤如下

  • - 定义Callable接口的实现类,并实现call()方法,该方法将作为线程执行体。
  • - 创建Callable实现类的实例,并以该实例作为参数,创建FutureTask对象。
  • - 使用FutureTask对象作为参数,创建Thread对象,然后启动线程。
  • - 调用FutureTask对象的get()方法,获得子线程执行结束后的返回值。

归纳起来,创建线程的方式实际只有两种:继承父类和实现接口。

而使用Runnable接口和Callable接口的方式,区别在于前者不能获得线程执行结束的返回值,后者可以获得线程执行结束的返回值。

而继承父类和实现接口这两种方式的优缺点是:

  • - 采用接口的方式创建线程,优点是线程类还可以继承于其他类,并且多个线程可以共享一个线程体,适合多个线程处理同一份资源的情况。缺点是编程稍微麻烦一点点。
  • - 采用继承的方式创建线程,优点是编程稍微简单一点点。缺点是因为线程类已经继承了Thread类,所以就不能继承其他的父类了

所以,通常情况下,更推荐采用接口的方式来创建线程。如果需要返回值,就使用Callable接口,否则使用Runnable接口即可。

14.说说你对线程池的理解

得分点

核心参数、处理流程、拒绝策略、生命周期

标准回答

线程池可以有效的管理线程:管理线程数量、让线程复用。

线程池的生命周期包含5个状态: RUNNING、SHUTDOWN、STOP、TIDING、TERMINATED。这5种状态的状态值分别是:-1、0、1、2、3。在线程池的生命周期中,它的状态只能由小到大迁移,是不可逆的。

线程池的5个状态:running、shutdown、stop、tiding、terminated。

线程的六个状态 :new runnable blocked waiting timed_waiting terminated

线程池可以有效地管理线程:

它可以管理线程的数量,可以避免无节制的创建线程,导致超出系统负荷直至崩溃。

它还可以让线程复用,可以大大地减少创建和销毁线程所带来的开销。

线程池需要依赖一些参数来控制任务的执行流程,其中最重要的参数有:corePoolSize(核心线程数)、workQueue(等待队列)、maxinumPoolSize(最大线程数)、handler(拒绝策略)、keepAliveTime(空闲线程存活时间)。当我们向线程池提交一个任务之后,线程池按照如下步骤处理这个任务:

1. 判断线程数是否达到corePoolSize,若没有则新建线程执行该任务,否则进入下一步。

2. 判断等待队列是否已满,若没有则将任务放入等待队列,否则进入下一步。

3. 判断线程数是否达到maxinumPoolSize,如果没有则新建线程执行任务,否则进入下一步。

4. 采用初始化线程池时指定的拒绝策略,拒绝执行该任务。

5. 新建的线程处理完当前任务后,不会立刻关闭,而是继续处理等待队列中的任务。

如果线程的空闲时间达到了keepAliveTime,则线程池会销毁一部分线程,将线程数量收缩至corePoolSize。 第2步中的队列可以有界也可以无界。若指定了无界的队列,则线程池永远无法进入第3步,相当于废弃了maxinumPoolSize参数。这种用法是十分危险的,如果任务在队列中产生大量的堆积,就很容易造成内存溢出。JDK为我们提供了一个名为Executors的线程池的创建工具,该工具创建出来的就是带有无界队列(不限制等待队列中的任务个数)的线程池,所以一般在工作中我们是不建议使用这个类来创建线程池的。 第4步中的拒绝策略主要有4个:让调用者自己执行任务、直接抛出异常、丢弃任务不做任何处理、删除队列中最老的任务并把当前任务加入队列。这4个拒绝策略分别对应着RejectedExecutionHandler接口的4个实现类,我们也可以基于这个接口实现自己的拒绝策略。 在Java中,线程池的实际类型为ThreadPoolExecutor,它提供了线程池的常规用法。该类还有一个子类,名为ScheduledThreadPoolExecutor,它对定时任务提供了支持。在子类中,我们可以周期性地重复执行某个任务,也可以延迟若干时间再执行某个任务。

加分回答

线程池的生命周期包含5个状态:RUNNING、SHUTDOWN、STOP、TIDING、TERMINATED。这5种状态的状态值分别是:-1、0、1、2、3。在线程池的生命周期中,它的状态只能由小到大迁移,是不可逆的。

1. RUNNING:表示线程池正在运行。

2. SHUTDOWN:执行shutdown()时进入该状态,此时队列不会清空,线程池会等待任务执行完毕。

3. STOP:执行shutdownNow()时进入该状态,此时现线程池会清空队列,不再等待任务的执行。

4. TIDING:当线程池及队列为空时进入该状态,此时线程池会执行钩子函数,目前该函数是一个空的实现。

5. TERMINATED:钩子函数执行完毕后,线程进入该状态,表示线程池已经死亡。

15.你知道哪些线程安全的集合?

得分点

Collections工具类、java.util.concurrent (JUC)

标准回答

线程安全的集合包括:

  1. Collections工具类的synchronizedXxx()方法将ArrayList等集合类包装成线程安全的集合类。
  2.  java.util包下性能差的古老api,如Vector、Hashtable
  3. JUC包下Concurrent开头的、以降低锁粒度来提高并发性能的容器,如ConcurrentHashMap。
  4. JUC包下以CopyOnWrite开头的、采用写时复制技术实现的并发容器,如CopyOnWriteArrayList。
  5. Lock实现的阻塞队列,内部创建两个Condition分别用于生产者和消费者的等待,这些类都实现了BlockingQueue接口,如ArrayBlockingQueue。

线程安全的集合:Collections工具类和concurrent包的集合类。

Collections工具类:该工具类提供的synchronizedXxx()方法,可以将这些集合类包装成线程安全的集合类。 

concurrent包的集合类:在java5之后可以使用concurrent包提供的大量的支持并发访问的集合类,例如ConcurrentHashMap/CopyOnWriteArrayList等。concurrent译为同时发生的,并存的,并发的

性能差的古老api:Vector、Hashtable。

java.util包下的集合类中,大部分都是非线程安全的,但也有少数的线程安全的集合类,例如Vector、Hashtable,它们都是非常古老的API。虽然它们是线程安全的,但是性能很差,已经不推荐使用了。对于这个包下非线程安全的集合,可以利用Collections工具类,该工具类提供的synchronizedXxx()方法,可以将这些集合类包装成线程安全的集合类。

从JDK 1.5开始,并发包下新增了大量高效的并发的容器,这些容器按照实现机制可以分为三类。

第一类是以降低锁粒度来提高并发性能的容器,它们的类名以Concurrent开头,如ConcurrentHashMap。

第二类是采用写时复制技术实现的并发容器,它们的类名以CopyOnWrite开头,如CopyOnWriteArrayList。

第三类是采用Lock实现的阻塞队列,内部创建两个Condition分别用于生产者和消费者的等待,这些类都实现了BlockingQueue接口,如ArrayBlockingQueue。

加分回答

Collections还提供了如下三类方法来返回一个不可变的集合,这三类方法的参数是原有的集合对象,返回值是该集合的“只读”版本。通过Collections提供的三类方法,可以生成“只读”的Collection或Map。

emptyXxx():返回一个空的不可变的集合对象

singletonXxx():返回一个只包含指定对象的不可变的集合对象 unmodifiableXxx():返回指定集合对象的不可变视图 

16.HashMap是线程安全的吗?如果不是该如何解决?

得分点

Hashtable、Collections、ConcurrentHashMap

标准回答

HashMap是线程安全的,底层实现是"数组、链表、红黑树",在多线程put时可能会造成数据覆盖,并且put会执行modCount++操作,这步操作分为读取、增加、保存,不是一个原子性操作。

解决办法:

  1. 使用Hashtable(古老)
  2. 使用Collections工具类将HashMap包装成线程安全的HashMap
  3. 使用更安全的CurrentHashMap(推荐),CurrentHashMap通过对桶加锁,以较小的性能来保证线程安全。

HashMap是非线程安全的,在多线程环境下,多个线程同时触发HashMap的改变时,有可能会发生冲突。所以,在多线程环境下不建议使用HashMap。

想要使用线程安全的HashMap,一共有三种办法:使用Hashtable、使用Collections将HashMap包装成线程安全的HashMap、使用ConcurrentHashMap,其中第三种方式最为高效,是我们最推荐的方式。

Hashtable

HashMap和Hashtable都是典型的Map实现,而Hashtable是线程安全的。虽然这算是一个可选方案,但却是不推荐的方案。因为Hashtable是一个古老的API,从Java 1.0开始就出现了,它的同步方案还不成熟、性能不好,甚至官方都给出了不推荐使用的建议。

Collections

Collections类中提供了synchronizedMap()方法,可以将我们传入的Map包装成线程同步的Map。除此以外,Collections还提供了如下三类方法来返回一个不可变的集合,这三类方法的参数是原有的集合对象,返回值是该集合的“只读”版本。通过Collections提供的三类方法,可以生成“只读”Map。 emptyMap():返回一个空的不可变的Map对象。 singletonMap():返回一个只包含指定键值对的不可变的Map对象。 unmodifiableMap() :返回指定Map对象的不可变视图。

ConcurrentHashMap

ConcurrentHashMap是线程安全且高效的HashMap,并且在JDK 8中进行了升级,使其在JDK 7的基础上进一步降低了锁的粒度,从而提高了并发的能力。 在JDK 7中ConcurrentHashMap的底层数据结构为“数组+链表”,但是为了降低锁的粒度,JDK7将一个Map拆分为若干子Map,每一个子Map称为一个段。多个段之间是相互独立的,而每个段都包含若干个槽,段中数据发生碰撞时采用链表结构解决。在并发插入数据时,ConcurrentHashMap锁定的是段,而不是整个Map。因为锁的粒度是段,所以这种模式也叫“分段锁”。另外,段在容器初始化的时候就被确定下来了,之后不能更改。而每个段是可以独立扩容的,各个段之间互不影响,所以并不存在并发扩容的问题。 在JDK8中ConcurrentHashMap的底层数据结构为“数组+链表+红黑树”,但是为了进一步降低锁的粒度,JDK8取消了段的设定,而是直接在Map的槽内存储链表或红黑树。并发插入时它锁定的是头节点,相比于段头节点的个数是可以随着扩容而增加的,所以粒度更小。引入红黑树,则是为了在冲突剧烈时,提高查找槽内元素的效率。

17.请你说说JUC

得分点

原子类、锁、线程池、并发容器、同步工具

标准回答

JUC是java.util.concurrent的缩写,这个包中包含了支持并发操作的各种工具。

1.原子类:在JUC.Atomic包下,提供了诸多原子操作的方法,遵循CAS(比较和替换)原则。可以用于解决单个变量的线程安全问题

2.Lock锁:与Synchronized类似,在包含synchronized所有功能的基础上,还支持超时机制,响应中断机制,主要用于解决多个变量的线程安全问题

3.线程池:可以更方便的管理线程,同时避免重复开线程和杀线程带来的消耗,效率高。

4.并发容器:例如ConcurrentHashMap,支持多线程操作的并发集合,效率更快。

JUC是java.util.concurrent的缩写,这个包是JDK 1.5提供的并发包,包内主要提供了支持并发操作的各种工具。这些工具大致分为如下5类:原子类、锁、线程池、并发容器、同步工具。

 1. 原子类 从JDK 1.5开始,并发包下提供了atomic子包,这个包中的原子操作类提供了一种用法简单、性能高效、线程安全地更新一个变量的方式。在atomic包里一共提供了17个类,属于4种类型的原子更新方式,分别是原子更新基本类型、原子更新引用类型、原子更新属性、原子更新数组。

对于原子类,它的内部提供了诸多原子操作的方法。如原子替换整数值、增加指定的值、加1,这些方法的底层便是采用操作系统提供的CAS原子指令来实现的。可以用于解决单个变量的线程安全问题

以AtomicInteger为例,某线程调用该对象的incrementAndGet()方式自增时采用CAS尝试修改它的值,若此时没有其他线程操作该值便修改成功否则反复执行CAS操作直到修改成功。

 2. 锁 从JDK 1.5开始,并发包中新增了Lock接口以及相关实现类用来实现锁功能,它提供了与synchronized关键字类似的同步功能,只是在使用时需要显式地获取和释放锁。虽然它缺少了隐式获取释放锁的便捷性,但是却拥有了多种synchronized关键字所不具备的同步特性,包括:可中断地获取锁、非阻塞地获取锁、可超时地获取锁。

 3. 线程池 从JDK 1.5开始,并发包下新增了内置的线程池。其中,ThreadPoolExecutor类代表常规的线程池,而它的子类ScheduledThreadPoolExecutor对定时任务提供了支持,在子类中我们可以周期性地重复执行某个任务,也可以延迟若干时间再执行某个任务。此外,Executors是一个用于创建线程池的工具类,由于该类创建出来的是带有无界队列的线程池,所以在使用时要慎重。

 4. 并发容器 从JDK 1.5开始,并发包下新增了大量高效的并发的容器,这些容器按照实现机制可以分为三类。第一类是以降低锁粒度来提高并发性能的容器,它们的类名以Concurrent开头,如ConcurrentHashMap。第二类是采用写时复制技术实现的并发容器,它们的类名以CopyOnWrite开头,如CopyOnWriteArrayList。第三类是采用Lock实现的阻塞队列,内部创建两个Condition分别用于生产者和消费者的等待,这些类都实现了BlockingQueue接口,如ArrayBlockingQueue。

 5. 同步工具 从JDK 1.5开始,并发包下新增了几个有用的并发工具类,一样可以保证线程安全。其中,Semaphore类代表信号量,可以控制同时访问特定资源的线程数量;CountDownLatch类则允许一个或多个线程等待其他线程完成操作;CyclicBarrier可以让一组线程到达一个屏障时被阻塞,直到最后一个线程到达屏障时,屏障才会打开,所有被屏障拦截的线程才会继续运行。

18.请你说说ConcurrentHashMap

得分点

数组+链表+红黑树、锁的粒度

标准答案 

concurrent包的集合类是线程安全的。

ConcurrentHashMap是在JUC(Java.util.concurrent并发包下的一个类,它相当于是一个线程安全的HashMap。 

数组+链表+红黑树:ConcurrentHashMap的底层数据结构与HashMap一样,也是采用“数组+链表+红黑树”

锁的粒度:采用锁定头节点的方式降低了锁粒度,以较低的性能代价实现了线程安全。

实现机制:

1. 初始化数组或头节点时,ConcurrentHashMap并没有加锁,而是CAS的方式进行原子替换

2. 哈希查找插入数据时会进行加锁处理,但锁定的不是整个数组,而是槽中的链表头节点。所以,ConcurrentHashMap中锁的粒度是槽,而不是整个数组,并发的性能很好。

3. 链地址法处理冲突扩容时会进行加锁处理,锁定的仍然是头节点。并且,支持多个线程同时对数组扩容,提高并发能力。

4. 在扩容的过程中,依然可以支持查找操作。

底层数据结构的逻辑可以参考HashMap的实现,下面我重点介绍它的线程安全的实现机制。

1. 初始化数组或头节点时,ConcurrentHashMap并没有加锁,而是CAS(比较并交换)的方式进行原子替换(原子操作,基于Unsafe类的原子操作API)。

CAS,compare and swap,解决多线程并行情况下使用锁造成性能损耗的一种机制。

CAS操作包含三个操作数——内存位置(V)、预期原值(A)和新值(B)。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。否则,处理器不做任何操作。无论哪种情况,它都会在CAS指令之前返回该位置的值。CAS有效地说明了“我认为位置V应该包含值A;如果包含该值,则将B放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。

2. 插入数据时会进行加锁处理,但锁定的不是整个数组,而是槽中的头节点。所以,ConcurrentHashMap中锁的粒度是槽,而不是整个数组,并发的性能很好。

3. 扩容时会进行加锁处理,锁定的仍然是头节点。并且,支持多个线程同时对数组扩容,提高并发能力。每个线程需先以CAS操作抢任务,争抢一段连续槽位的数据转移权。抢到任务后,该线程会锁定槽内的头节点,然后将链表或树中的数据迁移到新的数组里。

4. 查找数据时并不会加锁,所以性能很好。另外,在扩容的过程中,依然可以支持查找操作。如果某个槽还未进行迁移,则直接可以从旧数组里找到数据。如果某个槽已经迁移完毕,但是整个扩容还没结束,则扩容线程会创建一个转发节点存入旧数组,届时查找线程根据转发节点的提示,从新数组中找到目标数据。

加分回答

ConcurrentHashMap实现线程安全的难点在于多线程并发扩容,即当一个线程在插入数据时,若发现数组正在扩容,那么它就会立即参与扩容操作,完成扩容后再插入数据到新数组。在扩容的时候,多个线程共同分担数据迁移任务,每个线程负责的迁移数量是 `(数组长度 >>> 3) / CPU核心数`。 也就是说,为线程分配的迁移任务,是充分考虑了硬件的处理能力的。多个线程依据硬件的处理能力,平均分摊一部分槽的迁移工作。另外,如果计算出来的迁移数量小于16,则强制将其改为16,这是考虑到目前服务器领域主流的CPU运行速度,每次处理的任务过少,对于CPU的算力也是一种浪费。 

猜你喜欢

转载自blog.csdn.net/qq_40991313/article/details/129446871