Java八股复习指南-并发

Java并发

线程

进程与线程

进程:

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

线程:

线程是比进程更小的执行单位,一个进程可以产生多个线程。线程的产生或者切换时的负担比进程要小得多。

多个线程共享进程的堆和方法区,而程序计数器、虚拟机栈和本地方法栈是私有的

程序计数器:记录当前线程的执行位置,是为了线程切换时能恢复到正确的执行位置

虚拟机栈和本地方法栈:保证线程中的局部变量不被别的线程访问到

线程的实现

在 Java 中,实现多线程的主要有以下四种

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

可以单独执行run方法吗?

调用start()方法,会启动线程并使线程进入就绪态,等待时间片的分配,从而自动执行run()方法。

直接执行run()方法,则会把run()方法作为main中的一个方法去执行,并不是一个多线程的工作。

  1. 实现 Runnable 接口,实现 run() 方法,并将 Runnable 实现类的实例作为 Thread 构造函数的参数 target;

可以将任务和线程的逻辑分离,提供了更好的灵活性和重用性。你可以将一个 Runnable 对象传递给多个线程实例。

  1. 实现 Callable 接口,实现 call() 方法,然后通过 FutureTask 包装器来创建 Thread 线程;

可以返回结果并处理异常。Callablecall() 方法可以返回值(V),并且可以抛出异常(Exception)。

  1. 通过 ThreadPoolExecutor 创建线程池,并从线程池中获取线程用于执行任务;

不使用Executors创建,而是用ThreadPoolExecutor创建

通过工厂模式,将创建产品实例的权利移交工厂,我们不再通过new来创建我们所需的对象,而是通过工厂来获取我们需要的产品。降低了产品使用者与使用者之间的耦合关系;创建线程池没有显式new,而是通过Executors这个静态方法newCaChedThreadPool来完成的;

严格来说,Java 就只有一种方式可以创建线程,那就是通过new Thread().start()创建。不管是哪种方式,最终还是依赖于new Thread().start()


Thread类与Runnable接口比较:

实现一个自定义的线程类,可以有继承Thread类或者实现Runnable接口的方式,由于java单继承、多实现的特性,Runnable接口使用比Thread更加灵活

Callable接口:

通常来说,我们使用RunnableThread来创建一个新的线程。但是它们有一个弊端,就是run方法是没有返回值的。而有时候我们希望开启一个线程去执行一个任务,并且这个任务执行完成后有一个返回值

JDK提供了Callable接口与Future类为我们解决这个问题,这也是所谓的“异步”模型。

线程的六种状态:

// Thread.State 源码
public enum State {
    NEW,
    RUNNABLE,
    BLOCKED,
    WAITING,
    TIMED_WAITING,
    TERMINATED;
}
  1. NEW:

初始状态,线程被创建出来但没有被调用start()

  • 不能反复调用同一个线程的start()方法
  • 处于TERMINATED状态的线程不能再次调用start()方法
  1. RUNNABLE:

运行状态。处于RUNNABLE状态的线程在Java虚拟机中运行,也有可能在等待其他系统资源(比如I/O)。

  1. BLOCKED:

阻塞状态。处于BLOCKED状态的线程正等待锁的释放以进入同步区。

  1. WAITING

等待状态。处于等待状态的线程变成RUNNABLE状态需要其他线程唤醒。

以下方法使进程进入等待状态:

  • Object.wait()
  • Thread.join()
  • LockSupport.park()
  1. TIMED_WAITING

超时等待状态。线程等待一个具体的时间,时间到后会被自动唤醒。

  1. TERMINATED

终止状态。此时线程已执行完毕。

Java线程间的通信:

锁与同步

在Java中,锁的概念都是基于对象的,所以我们又经常称它为对象锁。线程和锁的关系,我们可以用婚姻关系来理解。一个锁同一时间只能被一个线程持有。也就是说,一个锁如果和一个线程“结婚”(持有),那其他线程如果需要得到这个锁,就得等这个线程和这个锁“离婚”(释放)。

等待/通知机制

Java多线程的等待/通知机制是基于Object类的wait()方法和notify(), notifyAll()方法来实现的。

信号量

使用volitile关键字实现信号量。volitile关键字能够保证内存的可见性,如果用volitile关键字声明了一个变量,在一个线程里面改变了这个变量的值,那其它线程是立马可见更改后的值的。

其他通信相关

join方法

join()方法是Thread类的一个实例方法。它的作用是让当前线程陷入“等待”状态,等join的这个线程执行完成后,再继续执行当前线程。

sleep方法

sleep方法是Thread类的一个静态方法。它的作用是让当前线程睡眠一段时间。

wait方法与sleep方法的区别:

sleep仅仅释放cpu资源,不会释放锁,所以易死锁。而wait方法会释放cpu资源,也会释放当前锁。

为什么wait()方法不定义在Thread中、sleep()方法定义在Thread中?

wait()方法:是要线程释放占用的对象锁,因此操作的对象锁是在对象类层面而不是某个线程

sleep()方法:仅仅是让线程暂停执行,不涉及到对象的操作

多线程

并发与并行的区别

  • 并发:两个及以上的作业在同一时间段内同时进行
  • 并行:两个及以上的作业在同一时刻进行

同步与异步的区别

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

死锁

什么是线程死锁?

多个线程同时被阻塞,其中的一个或多个都在等待某资源的释放,由于线程被无期限地阻塞,因此程序无法正常终止。

如何预防和避免线程死锁?

如何预防?

  • 破坏请求与保持的条件:一次性申请所有的资源
  • 破坏不剥夺条件:占用部分资源的线程在申请其他资源时,如果被占用,可以主动释放占用的资源。
  • 破环循环等待的条件:按顺序申请资源,按反序释放资源。

原理篇JMM(Java内存模型)

Java内存模型基础知识


Java中采取的是一种共享内存的并发模型
这里的共享,并不是指线程A与线程B之间完全可见,而是针对线程的本地内存与主内存之间变量共享。
在写操作时,线程操作变量,是只对本地内存中的变量进行修改,然后线程再将已修改的的变量刷新到主内存中。
而在读操作时,主内存中修改的变量会同步到线程的本地内存,线程也是只读取本地内存。
在其中发挥作用,可以监控到变量的变化的,就是JMM的作用了。

重排序与happens-before(基础概念)

重排序
计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排。
指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致。所以在多线程下,指令重排序可能会导致一些问题。
为了防止问题出现,引入顺序一致性模型

顺序一致性模型与JMM实现
顺序一致性模型是一个理论参考模型,内存模型在设计的时候都会以顺序一致性内存模型作为参考。
正因为它是一个理论模型,在现实中难以实现,JMM的实现方针是,在确保不改变程序结果的前提下,尽可能为重排序提供便利

happens-before
JMM的实现要考虑到两方面:
一方面,编译器与优化器需要JMM对它们的束缚尽可能的少,这样他们就能尽可能多的进行优化。
另一方面,程序员在编写多线程程序时,希望简单依赖程序中的顺序理解数据的交互与线程的执行顺序。
因此JMM提供了happens-before的规则,程序员只要遵循这个规则,就可以保证程序正确执行。

在Java中,有以下天然的happens-before关系:

  • 程序顺序规则:一个线程中的每一个操作,happens-before于该线程中的任意后续操作。

  • 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。

  • volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。

  • 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。

  • start规则:如果线程A执行操作ThreadB.start()启动线程B,那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作、

  • join规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。

所以,我们只关心happens-before规则,不用关心JVM到底是怎样执行的。只要确定操作A happens-before操作B就行了。

volatile

volatile 主要由以下两个功能

  • 保证内存可见性
  • 禁止重排序,但不保证原子性

如何保证变量的可见性?

强制在主存中读写变量:
当线程对volatile修饰的变量进行写操作时,JMM会立即将修改后的变量刷新到主存中。
当线程进行读操作时,JMM会使volatile修饰的变量的缓存立即失效,使线程从主存中读取变量数据。

如何禁止重排序?

通过插入特定的 内存屏障 的方式来禁止指令重排序。内存屏障的作用是:

  1. 阻止屏蔽两边的指令重排序
  2. 强制将写缓冲区/高速缓冲区的缓存写入主存中,或者让缓存中数据失效

volatile的用途
在保证变量可见性这点上,可以用来作为"轻量级"的锁,对比锁,volatile只能保证单个变量具有原子性,而锁可以保证整个临界区代码的原子性。
在禁止重排序上,也可以防止一些重排序的发生。

synchronized与锁

我们通常使用synchronized关键字来给一段代码或一个方法上锁。它通常有以下三种形式:

// 关键字在实例方法上,锁为当前实例
public synchronized void instanceLock() {
    // code
}

// 关键字在静态方法上,锁为当前Class对象
public static synchronized void classLock() {
    // code
}

// 关键字在代码块上,锁为括号里面的对象
public void blockLock() {
    Object o = new Object();
    synchronized (o) {
        // code
    }
}

四种状态:无锁、偏向锁、轻量级锁、重量级锁
锁的状态是通过Java对象头来实现的

无锁->偏向锁->轻量级锁->重量级锁

  • 偏向锁:

顾名思义,它会偏向于第一个访问锁的线程,如果在接下来的运行过程中,该锁没有被其他的线程访问,则持有偏向锁的线程将永远不需要触发同步。
如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会尝试消除它身上的偏向锁,将锁恢复到标准的轻量级锁。

更好理解的就是,对锁置一个true,如果为true,则代表没有资源竞争,那么就不需要进行加锁/解锁等操作,而是当作普通变量一样获取。如果有资源竞争,则将该值置为false,走加锁/解锁的流程

偏向锁的实现:
如果占用偏向锁的线程退出了,则涉及到一个锁ID的切换的问题。偏向锁会在第一次进入同步块时存放偏向的线程A的ID,这样在该线程A每次进入和退出同步块时不用进行同步操作消耗资源。
如果在线程A退出后,另一个线程B占用了这个锁,此时偏向锁的ID并不是指向线程B的,所以线程B会尝试修改偏向锁的ID指向B,如果成功,则说明线程A已经退出了。如果失败,则说明线程A、B在竞争该锁,此时偏向锁会升级成轻量级锁。

  • 轻量级锁:

是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。

轻量级锁的加锁:

若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。

  • 重量级锁:

在轻量级锁状态下,如果有第三个来访时,就会自动升级成重量级锁

优点 缺点 适用场景
偏向锁 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。 如果线程间存在锁竞争,会带来额外的锁撤销的消耗。 适用于只有一个线程访问同步块场景。
轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度。 如果始终得不到锁竞争的线程使用自旋会消耗CPU。 追求响应时间。同步块执行速度非常快。
重量级锁 线程竞争不使用自旋,不会消耗CPU。 线程阻塞,响应时间缓慢。 追求吞吐量。同步块执行速度较长。

CAS与原子操作

乐观锁与悲观锁

悲观锁:

它总是认为每次访问共享资源时会发生冲突,所以必须对每次数据操作加上锁,以保证临界区的程序同一时间只能有一个线程在执行。synchronizedReentrantLock等独占锁就是悲观锁思想的实现。

乐观锁:

乐观锁总是假设对共享资源的访问没有冲突,线程可以不停地执行,无需加锁也无需等待。而一旦多个线程发生冲突,乐观锁通常是使用一种称为CAS的技术来保证线程执行的安全性。乐观锁天生免疫死锁

乐观锁多用于“读多写少“的环境,避免频繁加锁影响性能;而悲观锁多用于”写多读少“的环境,避免频繁失败和重试影响性能。

CAS

如果有一个多个线程共享的变量i原本等于5,我现在在线程A中,想把它设置为新的值6;

CAS的全称是:比较并交换(Compare And Swap)。在CAS中,有这样三个值:

  • V:要更新的变量(var),指代变量(i)
  • E:预期值(expected),指旧值(5)
  • N:新值(new),指要设置的值(6)

比较并交换的过程如下:

判断V是否等于E,如果等于,将V的值设置为N;如果不等,说明已经有其它线程更新了V,则当前线程放弃更新,什么都不做。

AQS:

实现各种同步器(如锁、信号量、读写锁等)的基础

核心:

  • 状态:state

表示同步状态,用于控制对共享资源的访问

  • 控制线程抢锁和配合的FIFO队列(双向链表)

每个等待获取同步状态的线程都会被封装成一个 Node 对象,并按照获取的顺序排入队列。

  • 期望协作工具类去实现的获取/释放等重要方法

线程池原理

线程池的参数

五个必需参数:

  • int corePoolSize:核心线程最大值(核心线程会一直存在,非核心线程如果长时间闲置,会被销毁)
  • int maximumPoolSize:线程总数最大值(核心线程+非核心线程)
  • int keepAliveTime:非核心线程闲置超时时长
  • Timeunit unit:keepAliveTime的单位
  • BlockingQueue workQueue:阻塞队列,维护等待执行的Runnable任务对象

主要的任务处理流程:

任务处理流程

如何做到线程复用

ThreadPoolExecutor在创建线程时,会将线程封装成工作线程worker,并放入工作线程组中,然后这个worker反复从阻塞队列中拿任务去执行。

首先去执行创建这个worker时就有的任务,当执行完这个任务后,worker的生命周期并没有结束,在while循环中,worker会不断地调用getTask方法从阻塞队列中获取任务然后调用task.run()执行任务,从而达到复用线程的目的。只要getTask方法不返回null,此线程就不会退出。

如何创建线程池

  • 通过ThreadPoolExecutor构造函数来创建(推荐)。

  • 通过 Executor 框架的工具类 Executors 来创建。

参考-深入浅出Java多线程(https://redspider.gitbook.io/concurrent)

posted @ 2024-09-11 09:54  forest-pan  阅读(5)  评论(0编辑  收藏  举报