面试复习-JUC

JUC 与集合框架


logo

进程与线程

进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。

线程是一个比进程更小的执行单位

守护线程

守护线程,是指在程序运行的时候在后台提供一种通用服务的线程,只要任何非守护线程还在运行,程序就不会终止。image-20230409210102959

并发与并行的区别
  • 并发:两个及两个以上的作业在同一 时间段 内执行。
  • 并行:两个及两个以上的作业在同一 时刻 执行。

最关键的点是:是否是 同时 执行。

同步和异步的区别
  • 同步 : 发出一个调用之后,在没有得到结果之前, 该调用就不可以返回,一直等待。
  • 异步 :调用在发出之后,不用等待返回结果,该调用直接返回。

为什么要使用多线程?

先从总体上来说:

  • 从计算机底层来说: 线程可以比作是轻量级的进程,是程序执行的最小单位,线程间的切换和调度的成本远远小于进程。另外,多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销。
  • 从当代互联网发展趋势来说: 现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。
什么是上下文切换?

线程在执行过程中会有自己的运行条件和状态(也称上下文),比如上文所说到过的程序计数器,栈信息等。当出现如下情况的时候,线
程会从占用CPU状态中退出。

  • 主动让出CPU,比如调用了sleep(),wait()等。

  • 时间片用完,因为操作系统要防止一个线程或者进程长时间占用CPU导致其他线程或者进程饿死。

  • 调用了阻塞类型的系统中断,比如请求O,线程被阻塞。

  • 被终止或结束运行

​ 这其中前三种都会发生线程切换,线程切换意味着需要保存当前线程的上下文,留待线程下次占用cpu的时候恢复现场。并加载下一个将要占用CPU的线程上下文。这就是所谓的上下文切换。
​ 上下文切换是现代操作系统的基本功能,因其每次需要保存信息恢复信息,这将会占用CU,内存等系统资源进行处理,也就意味着效率会有一定损耗,如果频繁切换就会造成整体效率低下。

sleep wait方法

共同点 :两者都可以暂停线程的执行。

区别

  • sleep() 方法没有释放锁,而 wait() 方法释放了锁
  • wait() 通常被用于线程间交互/通信,sleep()通常被用于暂停执行
  • wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify()或者 notifyAll() 方法。sleep()方法执行完成后,线程会自动苏醒,或者也可以使用 wait(long timeout) 超时后线程会自动苏醒。
  • sleep()Thread 类的静态本地方法,wait() 则是 Object 类的本地方法。

JMM

JMM(Java Memory Model,Java内存模型)是用于定义Java虚拟机内多线程访问共享变量的规范,旨在保障多线程环境下程序的正确性、稳定性和可移植性。

在JMM中,所有的变量都存储在主内存中,每个线程都有自己的本地内存,用于存储该线程使用到的变量的副本。线程之间通过主内存来进行通信和数据同步,当一个线程对变量进行修改时,需要将修改后的结果刷新到主内存,其他线程再读取该变量时就可以获得最新的值。

为了保障多线程环境下程序的正确性,JMM定义了一系列规则和原则:

  • 原子性(Atomicity):对于某些操作(例如读-改-写操作),它们应该具有原子性,即线程执行这些操作后不能被其他线程打断
  • 可见性(Visibility):当一个线程修改了一个变量的值时,这个值应该立即对其他线程可见
  • 有序性(Ordering):程序的执行顺序必须符合预期,即指令的执行顺序不能发生乱序。

为了满足上述规则,JMM定义了以下概念:

  • 原子操作:不能被线程打断的操作,例如线程切换不能发生在原子操作中间。
  • 同步操作:用于保证可见性和有序性的操作,例如synchronized关键字、Lock接口等。
  • volatile关键字:用于保证可见性和有序性的关键字,对于声明为volatile的变量,写操作会直接刷新到主内存,读操作也会直接从主内存读取。
  • happens-before原则:如果一个操作执行了happens-before另外一个操作,那么第一个操作的结果对于第二个操作是可见的

**程序次序规则:**同一个线程中,按照程序的顺序,前面的操作happens-before后续的任何操作。
同一个线程内,代码的执行结果是有序的。其实就是,可能会发生指令重排,但是保证代码的执行结果一定是和按照顺序执行得到的一致,程序前面对某一个变量的修改一定对后续操作可见的,不可能会出现前面才把a修改为1,接着读a居然是修改前的结果,这也是程序运行最基本的要求。
**监视器锁规则:**对一个锁的解锁操作,happens-before后续对这个锁的加锁操作。
就是无论是在单线程环境还是多线程环境,对于同一个锁来说,一个线程对这个锁解锁之后,另一个线程获取了这个锁都能看到前一个线程的操作结果。比如前一个线程将变量x的值修改为了12并解锁,之后另一个线程拿到了这把锁,对之前线程的操作是可见的,可以得到x是前一个线程修改后的结果12(所以synchronized是有happens-before规则的)
**volatile变量规则:**对一个volatile变量的写操作happens-before后续对这个变量的读操作。
就是如果一个线程先去写一个volatile变量,紧接着另一个线程去读这个变量,那么这个写操作的结果一定对读的这个变量的线程可见。
**线程启动规则:**主线程A启动线程B,线程B中可以看到主线程启动B之前的操作。
在主线程A执行过程中,启动子线程B,那么线程A在启动子线程B之前对共享变量的修改结果对线程B可见。
**线程加入规则:**如果线程A执行操作join()线程B并成功返回,那么线程B中的任意操作happens-before线程Ajoin()操作成功返回。
**传递性规则:**如果A happens-before B,B happens-before C,那么A happens-before C。

原子性

问题提出,两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果是 0 吗?以上的结果可能是正数、负数、零

synchronized 解决并发问题,synchronized语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是synchronized是属于重量级操作,性能相对更低

image-20230409211433786
可见性
image-20230409211514941 image-20230409211542200

volatile 保证有序性和可见性

有序性
image-20230409211825318

这种情况下是:线程2执行ready=true,切换到线程1,进入if分支,相加为0,再切回线程2执行
num =2

这种现象叫做指令重排,是 JIT 编译器在运行时的一些优化,这个现象需要通过大量测试才能复现 volatile和sout 可以解决

锁机制

image-20230411175325539

其中最关键的就是monitorenter指令了,可以看到之后也有monitorexit与之进行匹配(注意这里有2个),monitorenter和monitorexit分别对应加锁和释放锁,在执行monitorenter之前需要尝试获取锁,每个对象都有一个monitor监视器与之对应,而这里正是去获取对象监视器的所有权,一旦monitor所有权被某个线程持有,那么其他线程将无法获得(管程模型的一种实现)。

synchronized

synchronized 关键字的使用方式主要有下面 3 种:

  1. 修饰实例方法
  2. 修饰静态方法
  3. 修饰代码块

Java 中的锁有很多种,其中最常见的是 synchronized 关键字、ReentrantLock 和 ReentrantReadWriteLock。synchronized 是一种悲观锁,在同步代码块或同步方法中,只能有一个线程持有锁;ReentrantLock 和 ReentrantReadWriteLock 则是一种可重入锁,允许一个线程多次获取同一把锁。

区别:

(1)synchronized 是 JVM 实现的一种内置锁,不需要显式地创建和释放锁,编译器会自动加上 monitorenter 和 monitorexit 指令,而 ReentrantLock 和 ReentrantReadWriteLock 是基于 AQS 实现的 API级别的锁。

(2)synchronized 是非公平锁,Lock 可以实现公平锁或非公平锁。

(3)synchronized 只有一种锁获取方式,即通过获取对象的内部锁来获得同步代码的访问权,而 ReentrantLock 和 ReentrantReadWriteLock 则提供了更灵活的获取锁的方式,如可重入锁、可中断锁、超时锁等。

适用场景:

(1)synchronized 更适合用于代码量少,简单的同步场景。

(2)ReentrantLock 适用于高度竞争的资源,并且需要进行可重入、公平或非公平锁。

(3)ReentrantReadWriteLock 适用于大部分读操作、少部分写操作的场景,可以显著提高并发性能。

锁升级

在Java中,使用synchronized关键字可以实现锁机制,保证线程安全。具体来说,synchronized关键字可以作用于代码块和方法,通过获取对象的锁来保证同一时刻只有一个线程可以执行该代码块或方法。

在JDK 6及之前的版本中,synchronized实现锁的方式是基于对象头的Mark Word来实现的。对象头包括了HashCode、GC信息以及Synchronized相关的标记位等信息。当执行synchronized块时,首先需要获取对应对象的监视器锁(Monitor),然后将该对象的Mark Word中的synchronized标志位置为“已锁定”状态,最后再将Monitor的计数器加1,表示当前线程持有该锁。当执行完synchronized块后,会将Monitor计数器减1,如果计数器为0,则将该对象的Mark Word中的synchronized标志位置为“未锁定”状态,表示该锁已经释放。

但是JDK 6之后的版本对synchronized锁做了优化,引入了锁升级的概念,将锁的状态从无锁、偏向锁、轻量级锁、重量级锁四个状态进行区分,可大大提高并发性能。

锁状态的升级过程如下:

  • 无锁状态:执行线程没有对该对象进行任何加锁操作,此时对象的Mark Word中的synchronized标志位为“未锁定”状态。
  • 偏向锁状态:当第一次有线程请求获取该对象的锁时,JVM会将该对象的Mark Word中的线程ID、偏向锁标志位以及偏向时间戳存储下来,表示当前线程已经获得了该对象的锁。之后,当其他线程请求获取该对象的锁时,JVM会先判断当前对象是否处于偏向锁状态,如果是,则判断当前线程的ID是否和该对象记录的ID相同,如果是,则表示当前线程已经拥有了该对象的锁;否则就需要使用CAS操作来竞争锁。偏向锁状态可以减少无竞争情况下的性能损失。
  • 轻量级锁状态:当多个线程请求获取该对象的锁时,JVM会将对象的**Mark Word中的指针指向当前线程的栈帧,**并将对象头的Mark Word替换为指向锁记录的指针。然后,JVM使用CAS指令来竞争锁,进行加锁和解锁操作,如果CAS成功,则表示该线程获得了该对象的锁,而该对象也进入了轻量级锁状态。
  • 重量级锁状态:CAS自选失败。JVM会使用重量级锁(也称为互斥量或者管程)来进行加锁和解锁操作,等待的线程会进入到Blocked状态,不能够获取该对象的锁,同时也不会占用CPU资源,降低了系统的性能。

锁升级过程是由JVM自动完成的,开发者无需手动干涉。同时,对于不同类型的锁状态,JVM都会采用最适合当前场景的加锁算法,从而提高了并发性能。

CAS

CAS 即 Compare and Swap ,它体现的一种乐观锁的思想,比如多个线程要对一个共享的整型变量执行 +1 操作

image-20230411181253376

CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗。 ABA问题 加入版本号

synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。

原子类

原子类是一种线程安全的类,它提供了一些操作,这些操作在执行时是不可被中断的,而且具有原子性。原子性是指一个操作要么执行完成,要么没有执行。原子类可以保证多个线程同时访问同一个变量时不会出现问题,从而避免了线程安全问题。

在 Java 中,原子类是通过利用 CAS(Compare and Swap)算法实现的 unsafe

image-20230411183843252

Lock和Condition接口

使用并发包中的锁和我们传统的synchronized锁不太一样,这里的锁我们可以认为是一把真正意义上的锁,每个锁都是一个对应的锁对象,我只需要向锁对象获取锁或是释放锁即可。我们首先来看看,此接口中定义了什么:

public interface Lock {
  	//获取锁,拿不到锁会阻塞,等待其他线程释放锁,获取到锁后返回
    void lock();
  	//同上,但是等待过程中会响应中断
    void lockInterruptibly() throws InterruptedException;
  	//尝试获取锁,但是不会阻塞,如果能获取到会返回true,不能返回false
    boolean tryLock();
  	//尝试获取锁,但是可以限定超时时间,如果超出时间还没拿到锁返回false,否则返回true,可以响应中断
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
  	//释放锁
    void unlock();
  	//暂时可以理解为替代传统的Object的wait()、notify()等操作的工具
    Condition newCondition();
}

这里我们可以演示一下,如何使用Lock类来进行加锁和释放锁操作:

public class Main {
    private static int i = 0;
    public static void main(String[] args) throws InterruptedException {
        Lock testLock = new ReentrantLock();   //可重入锁ReentrantLock类是Lock类的一个实现,我们后面会进行介绍
        Runnable action = () -> {
            for (int j = 0; j < 100000; j++) {   //还是以自增操作为例
                testLock.lock();    //加锁,加锁成功后其他线程如果也要获取锁,会阻塞,等待当前线程释放
                i++;
                testLock.unlock();  //解锁,释放锁之后其他线程就可以获取这把锁了(注意在这之前一定得加锁,不然报错)
            }
        };
        new Thread(action).start();
        new Thread(action).start();
        Thread.sleep(1000);   //等上面两个线程跑完
        System.out.println(i);
    }
}

可以看到,和我们之前使用synchronized相比,我们这里是真正在操作一个"锁"对象,当我们需要加锁时,只需要调用lock()方法,而需要释放锁时,只需要调用unlock()方法。程序运行的最终结果和使用synchronized锁是一样的。

那么,我们如何像传统的加锁那样,调用对象的wait()和notify()方法呢,并发包提供了Condition接口: 在调用newCondition()后,会生成一个新的Condition对象,并且同一把锁内是可以存在多个Condition对象的(实际上原始的锁机制等待队列只能有一个,而这里可以创建很多个Condition来实现多等待队列),而上面的例子中,实际上使用的是不同的Condition对象,只有对同一个Condition对象进行等待和唤醒操作才会有效,而不同的Condition对象是分开计算的。多个condition 可以定制通信

public interface Condition {
  	//与调用锁对象的wait方法一样,会进入到等待状态,但是这里需要调用Condition的signal或signalAll方法进行唤醒(感觉就是和普通对象的wait和notify是对应的)同时,等待状态下是可以响应中断的
 		void await() throws InterruptedException;
  	//同上,但不响应中断(看名字都能猜到)
  	void awaitUninterruptibly();
  	//等待指定时间,如果在指定时间(纳秒)内被唤醒,会返回剩余时间,如果超时,会返回0或负数,可以响应中断
  	long awaitNanos(long nanosTimeout) throws InterruptedException;
  	//等待指定时间(可以指定时间单位),如果等待时间内被唤醒,返回true,否则返回false,可以响应中断
  	boolean await(long time, TimeUnit unit) throws InterruptedException;
  	//可以指定一个明确的时间点,如果在时间点之前被唤醒,返回true,否则返回false,可以响应中断
  	boolean awaitUntil(Date deadline) throws InterruptedException;
  	//唤醒一个处于等待状态的线程,注意还得获得锁才能接着运行
  	void signal();
  	//同上,但是是唤醒所有等待线程
  	void signalAll();
  }

AQS抽象队列同步器

AQS(AbstractQueuedSynchronizer),是 Java 中用于实现锁和同步器的一个基础框架。AQS 提供了一些基础的同步工具,比如 ReentrantLock、Semaphore、CountDownLatch 等等,也可以用来自定义锁和同步器。

AQS 的基本思想是,使用一个 FIFO 的双向队列来记录被阻塞的线程,每个节点表示一个等待线程,当一个线程获取锁时,如果发现有等待的线程,则将其从队列中唤醒,让它们重新尝试获取锁。AQS 中的状态是核心概念之一,它表示同步状态,用 int 类型来表示。

AQS 提供了 acquire(int arg) 和 release(int arg) 两个方法,用于获取和释放锁,并根据当前状态判断是否需要阻塞或唤醒线程。

AQS 中主要包含了以下两种模式:独占模式和共享模式:

  1. 独占模式独占模式是指只有一个线程能够持有锁,其他线程要想获取锁就必须等待锁被释放。AQS 中的独占锁 ReentrantLock 就是基于独占模式实现的。

  2. 共享模式共享模式是指多个线程可以同时获取锁,比如读写锁就是一种共享锁。AQS 中的共享锁 ReentrantReadWriteLock 就是基于共享模式实现的

    img

image-20220306162015545

image-20230411184626185
//独占式获取同步状态,查看同步状态是否和参数一致,如果返没有问题,那么会使用CAS操作设置同步状态并返回true
protected boolean tryAcquire(int arg) {
    throw new UnsupportedOperationException();
}

//独占式释放同步状态
protected boolean tryRelease(int arg) {
    throw new UnsupportedOperationException();
}

//共享式获取同步状态,返回值大于0表示成功,否则失败
protected int tryAcquireShared(int arg) {
    throw new UnsupportedOperationException();
}

//共享式释放同步状态
protected boolean tryReleaseShared(int arg) {
    throw new UnsupportedOperationException();
}

//是否在独占模式下被当前线程占用(锁是否被当前线程持有)
protected boolean isHeldExclusively() {
    throw new UnsupportedOperationException();
}

@ReservedStackAccess //这个是JEP 270添加的新注解,它会保护被注解的方法,通过添加一些额外的空间,防止在多线程运行的时候出现栈溢出,下同
public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))   //节点为独占模式Node.EXCLUSIVE
        selfInterrupt();
}

image-20230411185243988

image-20220306141248030

image-20220306155509195

image-20230411185516238 image-20230411185559981

公平锁

可重入锁

读写锁

创建线程的方式

  1. 继承 Thread 类:创建一个继承自 Thread 类的类,并重写 run() 方法,然后创建该类的对象并调用 start() 方法启动线程。
  2. 实现 Runnable 接口:创建一个实现了 Runnable 接口的类,并重写 run() 方法,然后创建该类的对象,并将其传入 Thread 类的构造方法,最后调用 start() 方法启动线程。
  3. 实现 Callable 接口:创建一个实现了 Callable 接口的类,并重写 call() 方法,然后创建该类的对象,并将其传入 FutureTask 类的构造方法,再将 FutureTask 对象传入 Thread 类的构造方法,最后调用 start() 方法启动线程。
public class demo1 {
    public static class d1 extends  Thread{
        public void  run(){
            System.out.println("2222");
        }
    }
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        Thread thread = new Thread(() ->{
            System.out.println("1111");
        });
        thread.start();
        FutureTask<Integer> task = new FutureTask<Integer>(()->{
            return 111;
        });
        Thread thread1 = new Thread(task);
        thread1.start();
        System.out.println(task.get());
        d1 d1 = new d1();
        d1.start();
    }
}

ThreadLocal

hreadLocal类中包含了以上的三个主要的操作方法,其中定义了ThreadLocalMap这一内部类,顾名思义,这是一个类似HashMap的表结构,内部存储的确实也是(key,value)键值对,但内部只有数组,而没有链表,key是ThreadLocal对象,value是我们要操作的数。

虽说ThreadLocalMap定义在ThreadLocal类中,但是其维护实际是在Thread类中实现的,Thread类中有着ThreadLocal.ThreadLocalMap这样的属性,在调用set和get方法的时候,会首先获取该线程内的ThreadLocal.ThreadLocalMap对象,然后将ThreadLocal对象作为key存储进去(自己调用方法,然后把自己作为key存进去,interesting :) ),之所以要把ThreadLocal.ThreadLocalMap.Entry定义为数组,是因为每个线程中可能会创建多个ThreadLocal对象,所以用数组进行存储。

最终的变量是放在了当前线程的****ThreadLocalMap 中,并不是存在 ThreadLocal 上, ThreadLocal 可以理解为只是** ThreadLocalMap 的封装,传递了变量值。 ThrealLocal 类中可以通过 Thread.currentThread() 获取到当前线程对象后,直接通过 getMap(Thread t) 可以访问到该线程的 ThreadLocalMap 对象。

每个 Thread 中都具备⼀个 ThreadLocalMap ,⽽ ThreadLocalMap 可以存储以 ThreadLocal 为key Object对象为 value 的键值对。

image-20230411192942886

工具类

Java 集合框架主要包括三种类型的集合:

  1. List:一个有序的元素集合,可以包含重复的元素。常见的实现类有 ArrayList、LinkedList。

  2. Set:一个不允许包含重复元素的集合。常见的实现类有 HashSet、TreeSet。

  3. Map:一个键值对映射的集合。常见的实现类有 HashMap、TreeMap。

这些集合类都有着自己的适用场景和优劣势。比如,ArrayList 适用于读取列表中的数据,而 LinkedList 则适用于频繁插入或删除元素的情况;HashSet 适用于快速查找和添加元素,而 TreeSet 则可以进行排序等操作。

image-20230407203559765

hashmap

1.7 vs1.8

image-20230411194634963

  1. 结构区别

    红黑树加链表 链表

    • 一般情况下,以默认容量16为例,阈值等于12就扩容,单条链表能达到长度为8的概率是相当低的,除非Hash攻击或者HashMap容量过大出现某些链表过长导致性能急剧下降的问题,红黑树主要是为了结果这种问题。
    • 在正常情况下,效率相差并不大。
  2. 扩容区别

    在1.7中,当HashMap中元素的数量达到了负载因子(默认为0.75)乘以数组长度时,就会触发扩容操作。在扩容时,HashMap会创建一个新的数组,并将原来的元素重新分配到新的数组中去。而在1.8中,如果链表的长度超过了8,就会开始考虑将链表转换为红黑树;而如果红黑树中节点的数目小于等于6,就会考虑将红黑树重新转化为链表。只有在实际插入元素的时候,如果发现当前的元素超过了负载因子,才会考虑进行扩容

  3. hash

  4. 插入区别

    头插法vs 尾插法

    在1.7中,当添加元素时,先通过key的hashcode值计算出该元素在数组中的位置,然后再遍历对应位置的链表,查找是否已经存在相同的key,如果存在,则替换对应的value;否则,在链表头部添加新的元素节点。而在1.8中,类似于1.7,通过key的hashcode值计算出该元素在数组中的位置,然后进行操作。不同之处在于,在1.8中会尝试将当前位置的链表转化为红黑树,以提高查找效率。如果当前位置的链表长度超过了阈值(默认为8),就将其转化为红黑树。否则,就在链表头部添加新的元素节点。

  5. 节点区别

    image-20230411194320674

hashtable
对比项HashtableHashMap
线程安全性线程安全非线程安全
null 值支持不支持 key 和 value 为 null支持 key 和 value 为 null
效率效率较低,因为使用了 synchronized 关键字实现线程安全效率较高,但是需要注意并发修改问题
初始容量及扩容机制初始容量为11,扩容机制为当容量达到阈值(0.75)时进行扩容初始容量为16,扩容机制为当容量达到阈值(0.75)时进行扩容
迭代器Enumerator 和 Iterator 两种迭代器,都是 fail-fast 的Iterator 迭代器,fail-fast 特性

ConcurrentHashMap

1.7

image-20220308165304048

1.8

image-20220308230825627

Cas //无限循环,而且还是并发包中的类,盲猜一波CAS自旋锁 加锁

image-20230411201938243

public V put(K key, V value) {
    return putVal(key, value, false);
}

//有点小乱,如果看着太乱,可以在IDEA中折叠一下代码块,不然有点难受
final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException(); //键值不能为空,基操
    int hash = spread(key.hashCode());    //计算键的hash值,用于确定在哈希表中的位置
    int binCount = 0;   //一会用来记录链表长度的,忽略
    for (Node<K,V>[] tab = table;;) {    //无限循环,而且还是并发包中的类,盲猜一波CAS自旋锁
        Node<K,V> f; int n, i, fh;
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();    //如果数组(哈希表)为空肯定是要进行初始化的,然后再重新进下一轮循环
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {   //如果哈希表该位置为null,直接CAS插入结点作为头结即可(注意这里会将f设置当前哈希表位置上的头结点)
            if (casTabAt(tab, i, null,
                         new Node<K,V>(hash, key, value, null)))  
                break;                   // 如果CAS成功,直接break结束put方法,失败那就继续下一轮循环
        } else if ((fh = f.hash) == MOVED)   //头结点哈希值为-1,这里只需要知道是因为正在扩容即可
            tab = helpTransfer(tab, f);   //帮助进行迁移,完事之后再来下一次循环
        else {     //特殊情况都完了,这里就该是正常情况了,
            V oldVal = null;
            synchronized (f) {   //在前面的循环中f肯定是被设定为了哈希表某个位置上的头结点,这里直接把它作为锁加锁了,防止同一时间其他线程也在操作哈希表中这个位置上的链表或是红黑树
                if (tabAt(tab, i) == f) {
                    if (fh >= 0) {    //头结点的哈希值大于等于0说明是链表,下面就是针对链表的一些列操作
                        ...实现细节略
                    } else if (f instanceof TreeBin) {   //肯定不大于0,肯定也不是-1,还判断是不是TreeBin,所以不用猜了,肯定是红黑树,下面就是针对红黑树的情况进行操作
                      	//在ConcurrentHashMap并不是直接存储的TreeNode,而是TreeBin
                        ...实现细节略
                    }
                }
            }
          	//根据链表长度决定是否要进化为红黑树
            if (binCount != 0) {
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);   //注意这里只是可能会进化为红黑树,如果当前哈希表的长度小于64,它会优先考虑对哈希表进行扩容
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    addCount(1L, binCount);
    return null;
}

CopyOnWriteArrayList

image-20230407203548129

Java阻塞队列

方法描述队列已满/队列为空时的行为
add(E e)将指定的元素添加到队列末尾抛出 IllegalStateException 异常
put(E e)将指定的元素添加到队列末尾等待队列空间可用
offer(E e)将指定的元素添加到队列末尾返回 false
remove()从队列中移除队头元素抛出 NoSuchElementException 异常
poll(long timeout, TimeUnit unit)移除并返回队头元素在指定时间内等待队列非空,超时则返回 null
take()从队列中移除队头元素等待队列非空

image-20230407203915862

可以直接调用 Thread 类的 run 方法吗?

这是另一个非常经典的 Java 多线程面试问题,而且在面试中会经常被问到。很简单,但是很多人都会答不上来!

new 一个 Thread,线程进入了新建状态。调用 start()方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。 但是,直接执行 run() 方法,会把 run() 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。

总结: 调用 start() 方法方可启动线程并使线程进入就绪状态,直接执行 run() 方法的话不会以多线程的方式执行

线程池

创建核心线程->进等待队列->创建非核心线程->拒绝策略

  1. corePoolSize:核心线程池大小,我们每向线程池提交一个多线程任务时,都会创建一个新的核心线程,无论是否存在其他空闲线程,直到到达核心线程池大小为止,之后会尝试复用线程资源。当然也可以在一开始就全部初始化好,调用prestartAllCoreThreads()即可。

  2. maximumPoolSize:最大线程池大小,当目前线程池中所有的线程都处于运行状态,并且等待队列已满,那么就会直接尝试继续创建新的非核心线程运行,但是不能超过最大线程池大小。

  3. keepAliveTime:线程最大空闲时间,当一个非核心线程空闲超过一定时间,会自动销毁。

  4. unit:线程最大空闲时间的时间单位

  5. workQueue:线程等待队列,当线程池中核心线程数已满时,就会将任务暂时存到等待队列中,直到有线程资源可用为止,这里可以使用我们上一章学到的阻塞队列。

  6. threadFactory:线程创建工厂,我们可以干涉线程池中线程的创建过程,进行自定义。

  7. RejectedExecutionhandler:拒绝策略,当等待队列和线程池都没有空间了,真的不能再来新的任务时,来了个新的多线程任务,那么只能拒绝了,这时就会根据当前设定的拒绝策略进行处理。

    image-20230411204407038

fixedThreadPool

FixedThreadPool 被称为可重用固定线程数的线程池。通过 Executors 类中的相关源代码来看一下相关实现:

   /**
     * 创建一个可重用固定数量线程的线程池
     */
    public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactory threadFactory) {
        return new ThreadPoolExecutor(nThreads, nThreads,
                                      0L, TimeUnit.MILLISECONDS,
                                      new LinkedBlockingQueue<Runnable>(),
                                      threadFactory);
    }

另外还有一个 FixedThreadPool 的实现方法,和上面的类似,所以这里不多做阐述:

    public static ExecutorService newFixedThreadPool(int nThreads) {
        return new ThreadPoolExecutor(nThreads, nThreads,
                                      0L, TimeUnit.MILLISECONDS,
                                      new LinkedBlockingQueue<Runnable>());
    }

从上面源代码可以看出新创建的 FixedThreadPoolcorePoolSizemaximumPoolSize 都被设置为 nThreads,这个 nThreads 参数是我们使用的时候自己传递的。

即使 maximumPoolSize 的值比 corePoolSize 大,也至多只会创建 corePoolSize 个线程。这是因为FixedThreadPool 使用的是容量为 Integer.MAX_VALUELinkedBlockingQueue(无界队列),队列永远不会被放满。

  1. 如果当前运行的线程数小于 corePoolSize, 如果再来新任务的话,就创建新的线程来执行任务;
  2. 当前运行的线程数等于 corePoolSize 后, 如果再来新任务的话,会将任务加入 LinkedBlockingQueue
  3. 线程池中的线程执行完 手头的任务后,会在循环中反复从 LinkedBlockingQueue 中获取任务来执行;
为什么不推荐使用FixedThreadPool

FixedThreadPool 使用无界队列 LinkedBlockingQueue(队列的容量为 Integer.MAX_VALUE)作为线程池的工作队列会对线程池带来如下影响 :

  1. 当线程池中的线程数达到 corePoolSize 后,新任务将在无界队列中等待,因此线程池中的线程数不会超过 corePoolSize
  2. 由于使用无界队列时 maximumPoolSize 将是一个无效参数,因为不可能存在任务队列满的情况。所以,通过创建 FixedThreadPool的源码可以看出创建的 FixedThreadPoolcorePoolSizemaximumPoolSize 被设置为同一个值。
  3. 由于 1 和 2,使用无界队列时 keepAliveTime 将是一个无效参数;
  4. 运行中的 FixedThreadPool(未执行 shutdown()shutdownNow())不会拒绝任务,在任务比较多的时候会导致 OOM(内存溢出)。
SingleThreadExecutor

SingleThreadExecutor 是只有一个线程的线程池。下面看看SingleThreadExecutor 的实现:

   /**
     *返回只有一个线程的线程池
     */
    public static ExecutorService newSingleThreadExecutor(ThreadFactory threadFactory) {
        return new FinalizableDelegatedExecutorService
            (new ThreadPoolExecutor(1, 1,
                                    0L, TimeUnit.MILLISECONDS,
                                    new LinkedBlockingQueue<Runnable>(),
                                    threadFactory));
    }
   public static ExecutorService newSingleThreadExecutor() {
        return new FinalizableDelegatedExecutorService
            (new ThreadPoolExecutor(1, 1,
                                    0L, TimeUnit.MILLISECONDS,
                                    new LinkedBlockingQueue<Runnable>()));
    }

从上面源代码可以看出新创建的 SingleThreadExecutorcorePoolSizemaximumPoolSize 都被设置为 1,其他参数和 FixedThreadPool 相同。

  1. 如果当前运行的线程数少于 corePoolSize,则创建一个新的线程执行任务;
  2. 当前线程池中有一个运行的线程后,将任务加入 LinkedBlockingQueue
  3. 线程执行完当前的任务后,会在循环中反复从LinkedBlockingQueue 中获取任务来执行;
为什么不推荐使用SingleThreadExecutor

SingleThreadExecutorFixedThreadPool 一样,使用的都是容量为 Integer.MAX_VALUELinkedBlockingQueue(无界队列)作为线程池的工作队列。SingleThreadExecutor 使用无界队列作为线程池的工作队列会对线程池带来的影响与 FixedThreadPool 相同。说简单点,就是可能会导致 OOM。

CachedThreadPool

CachedThreadPool 是一个会根据需要创建新线程的线程池。下面通过源码来看看 CachedThreadPool 的实现:

    /**
     * 创建一个线程池,根据需要创建新线程,但会在先前构建的线程可用时重用它。
     */
    public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                      60L, TimeUnit.SECONDS,
                                      new SynchronousQueue<Runnable>(),
                                      threadFactory);
    }
    public static ExecutorService newCachedThreadPool() {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                      60L, TimeUnit.SECONDS,
                                      new SynchronousQueue<Runnable>());
    }

CachedThreadPoolcorePoolSize 被设置为空(0),maximumPoolSize被设置为 Integer.MAX.VALUE,即它是无界的,这也就意味着如果主线程提交任务的速度高于 maximumPool 中线程处理任务的速度时,CachedThreadPool 会不断创建新的线程。极端情况下,这样会导致耗尽 cpu 和内存资源。

  1. 首先执行 SynchronousQueue.offer(Runnable task) 提交任务到任务队列。如果当前 maximumPool 中有闲线程正在执行 SynchronousQueue.poll(keepAliveTime,TimeUnit.NANOSECONDS),那么主线程执行 offer 操作与空闲线程执行的 poll 操作配对成功,主线程把任务交给空闲线程执行,execute()方法执行完成,否则执行下面的步骤 2;
  2. 当初始 maximumPool 为空,或者 maximumPool 中没有空闲线程时,将没有线程执行 SynchronousQueue.poll(keepAliveTime,TimeUnit.NANOSECONDS)。这种情况下,步骤 1 将失败,此时 CachedThreadPool 会创建新线程执行任务,execute 方法执行完成;
为什么不推荐使用CachedThreadPool

CachedThreadPool 使用的是同步队列 SynchronousQueue, 允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。

ScheduledThreadPool

ScheduledThreadPool 用来在给定的延迟后运行任务或者定期执行任务。这个在实际项目中基本不会被用到,也不推荐使用,大家只需要简单了解一下即可。

public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
    return new ScheduledThreadPoolExecutor(corePoolSize);
}
public ScheduledThreadPoolExecutor(int corePoolSize) {
    super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
          new DelayedWorkQueue());
}

ScheduledThreadPool 是通过 ScheduledThreadPoolExecutor 创建的,使用的DelayedWorkQueue(延迟阻塞队列)作为线程池的任务队列。

DelayedWorkQueue 的内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构,可以保证每次出队的任务都是当前队列中执行时间最靠前的。DelayedWorkQueue 添加元素满了之后会自动扩容原来容量的 1/2,即永远不会阻塞,最大扩容可达 Integer.MAX_VALUE,所以最多只能创建核心线程数的线程。

ScheduledThreadPoolExecutor 继承了 ThreadPoolExecutor,所以创建 ScheduledThreadExecutor 本质也是创建一个 ThreadPoolExecutor 线程池,只是传入的参数不相同。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值