Java 多线程

51 阅读20分钟

并发编程的三个基本概念

  • 原子性:即一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
  • 可见性:指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
  • 有序性:即程序执行的顺序按照代码的先后顺序执行。

线程运行状态

start() 方法是准备执行,真正的执行要看操作系统的脸色,因为整体的线程处理有自己的一套运行的状态。

创建

在程序中用构造方法创建了一个线程对象后,新的线程对象便处于新建状态,此时它已经有了相应的内存空间和其他资源,但还处于不可运行状态。新建一个线程对象可采用 Thread 类的构造方法来实现,例如Thread thread = new Thread()

就绪

新建线程对象后,调用该线程的 start() 方法就可以启动线程。当线程启动时,线程进入就绪状态。此时线程将进入线程队列排队,等待 CPU 服务,这表明它已经具备了运行条件。

运行

当就绪状态被调用并获得处理器资源时,线程就进入了运行状态。此时自动调用该线程对象的 run() 方法。run() 方法定义该线程的操作和功能。

阻塞

一个正在执行的线程在某些特殊情况下,如被人为挂起或需要执行耗时的输入/输出操作,会让 CPU 暂时中止自己的执行,进入阻塞状态。在可执行状态下,如果调用 sleep(),suspend(),wait() 等方法,线程都将进入阻塞状态,发生阻塞时线程不能进入排队队列,只有当引起阻塞的原因被消除后,线程才可以转入就绪状态。

死亡

线程调用 stop() 方法时或 run() 方法执行结束后,即处于死亡状态。处于死亡状态的线程不具有继续运行的能力。

Java 程序每次运行至少启动两个线程,每当使用Java命令执行一个类时,实际上都会启动一个 JVM,每一个 JVM 实际上就是在操作系统中启动一个线程,Java 本身具备了垃圾的收集机制。所以在 Java 运行时至少会启动两个线程,一个 main 线程,另外一个是垃圾收集线程。

常用操作

强制执行

使用 join() 方法让一个线程强制运行,线程强制运行期间,其他线程无法运行,必须等待此线程完成之后才可以继续执行。

线程休眠

Thread.sleep() 用于在当前线程中暂停执行一段指定的时间

线程中断

Thread.interrupt() 方法不会中断一个正在运行的线程。

Thread.interrupt() 的作用并不是中断线程,而是「通知线程应该中断了」,具体到底中断还是继续运行,应该由被通知的线程自己处理。

如果线程处于被阻塞状态(例如处于sleep, wait, join 等状态),那么线程将立即退出被阻塞状态,并抛出一个 InterruptedException 异常。

如果线程处于正常活动状态,那么会将该线程的中断标志设置为 true,仅此而已。被设置中断标志的线程将继续正常运行,不受影响

守护线程

Thread.setDaemon() 用于将线程设置为守护线程。守护线程是一种特殊的线程,它的主要作用是为其他线程提供服务,而不是执行用户级的任务。

当一个进程中所有的非守护线程都结束时,守护线程也会自动终止。这意味着守护线程不会阻止进程的退出,即使它们还在运行。相反,它们会在主线程或其他非守护线程结束后立即终止。

线程优先级

Thread.setPriority() 用于设置线程的优先级。线程的优先级是一个整数,范围从1(最低优先级)到10(最高优先级)。较高优先级的线程会比较低优先级的线程更早执行,但线程的调度是由操作系统决定的,因此线程的优先级并不能保证线程一定会先执行。

线程礼让

Thread.yield() 用于让当前正在执行的线程放弃对 CPU 的控制权,以便其他线程有机会执行。它的作用是在当前线程执行到一个合适的位置时,主动暂停当前线程的执行,让其他线程有机会运行。

run() 方法和 start() 方法

run() 方法

  • run() 方法是定义在 Thread 类中的一个普通方法,用于定义线程的执行体。线程对象创建后,可以通过调用run() 方法来执行线程的逻辑。
  • run() 方法不会创建新的线程,而是在当前线程中执行。
  • 如果直接调用 run() 方法,它会作为普通方法在当前线程中执行,而不会创建新的线程。

start方法

  • start() 方法是定义在 Thread 类中的一个特殊方法,用于启动线程的执行。
  • start() 方法会创建一个新的线程,并将其与当前线程独立运行。
  • start() 方法会调用线程对象的 run() 方法,将线程的执行体放在新创建的线程中执行。

Thread 与 Runnable 的关系

  • Thread 是一个类,而 Runnable 是一个接口。
  • Thread 类实现了 Runnable 接口,Runnable 接口里只有一个抽象的 run() 方法。说明Runnable不具备多线程的特性。Runnable 依赖 Thread 类的 start() 方法创建一个子线程,再在这个子线程里调用 run() 方法,才能让 Runnable 接口具备多线程的特性。
  • 如果只是实现 Runnable 接口,并不能启动或者说实现一个线程。Runnable 接口并不能代表一个线程。Runnable 接口和线程是两个不同的概念!所以即使实现了 Runnable 接口,那也无法启动线程,必须依托其他类。实现 Runnable 接口后,需要使用 Thread 类来启动。

Callable

Runnable 接口有一个缺点:当线程执行完毕后,无法获取一个返回值,所以从 JDK1.5 之后就提出了一个新的线程实现接口:java.util.concurrent.Callable

Runnable 与 Callable 的区别

  1. Runnable 是在 JDK1.0 的时候提出的多线程的实现接口,而 Callable 是在 JDK1.5 之后提出的;
  2. java.lang.Runnable 接口之中只提供了一个 run() 方法,并且没有返回值;
  3. java.util.concurrent.Callable 接口提供有 call() ,可以有返回值;

ThreadLocal

可以指定线程进行存储数据,数据存储以后,只有指定线程可以得到存储数据。ThreadLocal 的每个 Thread 线程内部都有一个 Map 存储线程本地对象(key)和线程变量副本的值(value),Thread 内部的 Map 是由 ThreadLocal 维护的,由 ThreadLocal 负责向 map 获取和设置线程的变量值。

内存泄漏问题

ThreadLocal 在保存的时候会把自己当做 Key 存在 ThreadLocalMap 中,正常情况应该是 key 和 value 都应该被外界强引用才对,但是现在 key 被设计成 WeakReference 弱引用了,Key 因弱引用被 GC 了,但是 Value 还在,Value 就有可能一直得不到回收,发生内存泄露。

因为线程都是复用的,那么之前的线程实例处理完之后,出于复用的目的线程依然存活,所以 ThreadLocal 设定的 value 值被持有,导致内存泄露。按照道理一个线程使用完,ThreadLocalMap 是应该要被清空的,但是现在线程被复用了。所以最后用 remove 把值清空就好

Executors 的四种线程池

常见的线程池参数配置

  • 核心线程数(corePoolSize):线程池在没有任务时保持的最小线程数。当有任务到达时,线程池会创建新的线程来执行任务,直到达到核心线程数。
  • 最大线程数(maxPoolSize):线程池在处理任务时可以创建的最大线程数。当任务数量超过核心线程数时,线程池会创建新的线程来处理任务,直到达到最大线程数。如果任务数量继续增加,线程池会将任务放入队列中等待执行。
  • 空闲线程存活时间(keepAliveTime):线程在没有任务时保持存活的时间。如果线程在这段时间内没有被使用,它将被自动销毁。这个参数可以帮助控制线程池的大小,避免资源浪费。
  • 工作队列(workQueue):线程池用来存放待处理任务的队列。常用的工作队列有ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue等。工作队列的类型会影响线程池的行为和性能。
  • 拒绝策略(RejectedExecutionHandler):当线程池达到最大线程数且工作队列已满时,用于处理新任务的策略。常见的拒绝策略有AbortPolicy、CallerRunsPolicy、DiscardPolicy、DiscardOldestPolicy等。拒绝策略的选择取决于任务的重要性和业务需求。

可缓存线程池

Executors.newCachedThreadPool() 用于创建一个具有缓存功能的线程池。当有任务需要执行时,线程池会创建新的线程来执行任务,当任务执行完毕后,线程会被回收,以便下次使用。如果线程池长度超过,则会回收空闲线程,若无可回收,则新建线程。

固定长度线程池

Executors.newFixedThreadPool() 用于创建一个固定大小的线程池。这个方法接受一个整数参数,表示线程池的核心线程数,超出的线程会在队列中等待。

定时调度线程池

Executors.newScheduledThreadPool() 用于创建一个具有定时调度功能的线程池。这个方法接受一个整数参数,表示线程池的核心线程数。当执行任务的时间大于指定的间隔时间时,会等待该线程执行完毕。

单线程线程池

Executors.newSingleThreadExecutor() 用于创建一个单线程的线程池。它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。如果这个唯一的线程因为异常结束,那么会有一个新的线程来替代它。此线程池保证所有任务的执行顺序按照任务的提交顺序执行。

锁是为了解决多个线程访问同一个共享变量,当同时对共享变量进行读写操作时,就会产生数据不一致的问题。

竞态条件

竞态条件是指在多线程环境下,多个线程同时访问共享资源时,由于线程调度的不确定性,可能会导致竞态条件问题。竞态条件问题可能会导致数据不一致、死锁、活锁等问题。

锁的分类

乐观锁

每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量,在 java.util.concurrent.atomic 包下面的原子变量类就是使用了乐观锁的一种实现方式 CAS 实现的。

悲观锁

总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁,synchronized 关键字的实现就是悲观锁。

独享锁

是指该锁一次只能被一个线程所持有,Synchronized 是独享锁。

共享锁

是指该锁可被多个线程所持有。 注:独享锁/共享锁就是一种广义的说法,互斥锁/读写锁就是具体的实现。

互斥锁

保证来任一时刻,只有一个线程访问该对象,在 Java 中的具体实现就是 ReentrantLock。

读写锁

是共享锁的一种具体实现。读写锁管理一组锁,一个是只读的锁,一个是写锁。读锁可以在没有写锁的时候被多个线程同时持有,而写锁是独占的。写锁的优先级要高于读锁,一个获得了读锁的线程必须能看到前一个释放的写锁所更新的内容。读写锁相比于互斥锁并发程度更高,每次只有一个写线程,但是同时可以有多个线程并发读。在 Java 中的具体实现就是 ReadWriteLock。

可重入锁

可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。说的有点抽象。

Synchronized

synchronized 关键字是 Java 中的一个同步机制,用于实现线程安全。当一个方法或代码块被 synchronized 修饰时,在同一时刻只能有一个线程执行该方法或代码块。

加锁对象相同的话,同步方法锁的范围大于等于同步方法块。一般加锁范围越大,性能越差。

实现原理

代码块同步

  1. 当进入一个人方法的时候,执行 monitorenter,就会获取当前对象的一个所有权,这个时候 monitor 进入数为 1,当前的这个线程就是这个 monitor 的拥有者。
  2. 如果已经是这个 monitor 的拥有者了,你再次进入,就会把进入数 +1
  3. 当执行完 monitorexit,对应的进入数就 -1,直到为 0,才可以被其他线程持有。

拥有者 方法常量池中的方法表结构中有一个访问标志(ACC_SYNCHRONIZED),同步方法的时候,一旦执行到这个方法,就会先判断是否有标志位,然后,ACC_SYNCHRONIZED 会去隐式调用两个指令:monitorenter 和 monitorexit,还是 monitor 对象的争夺。

升级过程

在 JDK 1.8 后,synchronized 关键字的实现进一步优化,引入四种状态,无锁,偏向锁,轻量级锁,重量级锁,锁可以升级但不能降级,但是偏向锁状态可以被重置为无锁状态。

偏向锁

  • 线程1获取锁对象时,会在对象头和栈帧中记录偏向的锁的threadID
  • 线程1获取该锁时,比较threadID是否一致 ------- 一致 -> 直接进入而无需使用CAS来加锁、解锁
  • 线程2获取该锁时,比较threadID是否一致 ------- 不一致 -> 检查对象的threadID线程是否还存活
  • 不存活:那么锁对象被重置为无锁状态,其它线程(线程2)可以竞争将其设置为偏向锁
  • 存活:代表该对象被多个线程竞争,于是升级成轻量级锁

轻量级锁

对象被多个线程竞争时,锁由偏向锁升级为轻量级锁,轻量级锁采用自旋 + CAS 方式不断获取锁。并不是阻塞状态,而是循环一直等待,比较浪费 CPU 的资源。

重量级锁

当线程的自旋次数过长依旧没获取到锁或多个锁竞争发生在自旋阶段时,为避免 CPU 无端耗费,锁由轻量级锁升级为重量级锁。

获取锁的同时会阻塞其他正在竞争该锁的线程,依赖对象内部的监视器实现,监视器又依赖操作系统底层,需要从用户态切换到内核态,成本非常高。

用户空间:指的就是用户可以操作和访问的空间,这个空间通常存放我们用户自己写的数据等等

内核空间:是系统内核来操作的一块空间,这块空间里面存放系统内核的函数、接口等。

ReentrantLock

ReentrantLock 是基于 AQS 实现的锁,必须先获取锁,然后在 finally 中正确释放锁。默认实现的是非公平锁,如果想要实现公平锁,只需要参数传递 true 即可,同时也是可重入锁。

实现原理

  1. 通过CAS尝试获取锁
  2. 如果此时锁已经被占用,该线程加入 AQS 队列并 wait()
  3. 当前线程的锁被释放,队列为首的线程就会被 notify(),然后继续 CAS 尝试获取锁
  4. 非公平锁:如果有其他线程尝试 lock(),有可能被其他刚好申请锁的线程抢占。
  5. 公平锁:只有在队列头的线程才可以获取锁,新来的线程只能插入到队尾。

CAS

CAS 机制中使用了 3 个基本操作数:内存地址V,旧的预期值A,要修改的新值B。

更新一个变量的时候,只有当变量的预期值 A 和内存地址 V 当中的实际值相同时,才会将内存地址 V 对应的值修改为 B。

ABA问题

假设内存中有一个值为 A 的变量,存储在地址 V 中。

此时有三个线程想使用 CAS 的方式更新这个变量的值,每个线程的执行时间有略微偏差。线程 1 和线程 2 已经获取当前值,线程 3 还未获取当前值。

线程动作
线程 1获取当前值A,期望更新为B
线程 2获取当前值A,期望更新为B
线程 3期望更新为A

接下来,线程 1 先一步执行成功,把当前值成功从 A 更新为 B;同时线程2因为某种原因被阻塞住,没有做更新操作;线程 3 在线程 1 更新之后,获取了当前值 B。

线程动作
线程 1获取当前值A,成功更新为B
线程 2获取当前值A,期望更新为B,Block
线程 3获取当前值B,期望更新为A

在之后,线程 2 仍然处于阻塞状态,线程 3 继续执行,成功把当前值从 B 更新成了 A。

线程动作
线程 1获取当前值A,成功更新为B,已返回
线程 2获取当前值A,期望更新为B,Block
线程 3获取当前值B,期望更新为A

最后,线程 2 终于恢复了运行状态,由于阻塞之前已经获得了“当前值A”,并且经过 compare 检测,内存地址 V 中的实际值也是 A,所以成功把变量值 A 更新成了 B。

线程动作
线程 1获取当前值A,成功更新为B,已返回
线程 2获取“当前值”A,成功更新为B
线程 3获取当前值B,成功更新为A,已返回

所以真正要做到严谨的 CAS 机制,在 compare 阶段不仅要比较期望值 A 和地址 V 中的实际值,还要比较变量的版本号是否一致。

假设地址 V 中存储着变量值 A,当前版本号是 01。线程 1 获取了当前值 A 和版本号 01,想要更新为 B,但是被阻塞了。

这时候,内存地址 V 中变量发生了多次改变,版本号提升为 03,但是变量值仍然是 A。

随后线程 1 恢复运行,进行 compare 操作。经过比较,线程 1 所获得的值和地址的实际值都是 A,但是版本号不相等,所以这一次更新失败。

AQS

AQS是并发容器 JUC 下 locks 包内的一个类,也是一个用于构建锁和同步容器的框架。它实现了一个 FIFO(FirstIn、FirstOut先进先出) 的队列,底层实现的数据结构是一个双向链表。

核心思想

如果被请求的共享资源空闲,那么将当前请求资源的线程设置为有效的工作线程,将共享资源设置为锁定状态;

如果共享资源被占用,就需要一定的阻塞等待唤醒机制来保证锁分配。这个机制主要用的是 CLH 队列的变体实现的,将暂时获取不到锁的线程加入到队列中。

AQS 是通过将每条请求共享资源的线程封装成一个节点来实现锁的分配。

AQS 使用一个 volatile 修饰的 int 类型的成员变量来表示同步状态,通过内置的 FIFO 队列来完成资源获取的排队工作,通过 CAS 完成对 state 值的修改。例如 ReentrantLock 用它来表示线程重入锁的次数,Semphore 用它表示剩余的许可数量,FutureTask 用它表示任务的状态。对 state 变量值的更新都采用 CAS 操作保证更新操作的原子性。

共享方式

  1. Exclusive(独占,只有一个线程能执行,如ReentrantLock)
  2. Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch)

注:这也是为什么state值的类型是int而不是boolean的原因,因为在共享方式下,需要记录次数。

基于 AQS 的一些实现

CountDownLatch

countDownLatch 使一个线程在等待另外一些线程完成之后再继续执行,它是使用一个计数器进行实现的,计数器初始值为线程的数量,当一个线程完成自己任务后,计数器的值减一,当值为 0 时,表示所有线程均已完成,然后 countDownLatch 上等待的任务开始执行。

countDownLatch 是一次性的,计数器的值只能在构造方法中初始化一次,之后不能再进行设置,使用完毕后,不能再次被使用。

public class CountdownLatchTest1 {
    public static void main(String[] args) {
    
        ExecutorService service = Executors.newFixedThreadPool(3);
        final CountDownLatch latch = new CountDownLatch(3);
        
        for (int i = 0; i < 3; i++) {
            Runnable runnable = new Runnable() {
                @Override
                public void run() {
                    try {
                        System.out.println("子线程" + Thread.currentThread().getName() + "开始执行");
                        Thread.sleep(500);
                        System.out.println("子线程" + Thread.currentThread().getName() + "执行完成");
                        //当前线程调用此方法,则计数减一
                        latch.countDown();
                    } catch (InterruptedException e) {
                    }
                }
            }            
            service.execute(runnable);
        }
        
        try {
            System.out.println("主线程" + Thread.currentThread().getName() + "等待子线程执行完成...");
            // 阻塞当前线程,直到计数器的值为0
            latch.await();            
            System.out.println("主线程" + Thread.currentThread().getName() + "开始执行...");
        } catch (InterruptedException e) {
        }
    }
}

CyclicBarrier

现实生活中我们经常会遇到这样的情景,在进行某个活动前需要等待人全部都齐了才开始。例如吃饭时要等全家人都上座了才动筷子,旅游时要等全部人都到齐了才出发,比赛时要等运动员都上场后才开始。利用CyclicBarrier类可以实现一组线程相互等待,当所有线程都到达某个屏障点后再进行后续的操作。

CyclicBarrier 内部有一个计数器,每个线程在到达屏障点前的时候,都会调用 await() 方法将自己阻塞,此时计数器会减1,当计数器减为0的时候,所有调用 await() 方法被阻塞的线程都会被唤醒,这就是线程相互等待。

public class CyclicBarrierDemo {
    static class TaskThread extends Thread {
    
        CyclicBarrier barrier;
        
        public TaskThread(CyclicBarrier barrier) {
            this.barrier = barrier;
        }  
        
        @Override
        public void run() {
            try {
                Thread.sleep(1000);
                System.out.println(getName() + " 到达栅栏 A");
                barrier.await();
                System.out.println(getName() + " 冲破栅栏 A");
                Thread.sleep(2000);
                System.out.println(getName() + " 到达栅栏 B");
                barrier.await();
                System.out.println(getName() + " 冲破栅栏 B");
            } catch (Exception e) {
            }
        }
    }
    
    public static void main(String[] args) {
    
        int threadNum = 5;
        
        CyclicBarrier barrier = new CyclicBarrier(threadNum, new Runnable() {
            @Override
            public void run() {
                System.out.println(Thread.currentThread().getName() + " 完成最后任务");
            }
        });     
        
        for(int i = 0; i < threadNum; i++) {
            new TaskThread(barrier).start();
        }
    }
}