学习笔记-Java并发

285 阅读19分钟

线程

进程和线程有什么区别

(从JVM角度) 线程是进程划分出的更小程序执行单位,最大的区别在于进程是互相独立的,拥有自己的资源空间;但线程之间会互相影响,线程拥有自己程序计数器、虚拟机栈、本地方法栈,而堆和方法区是共享的。

根本区别:进程是操作系统资源分配的基本单位,而线程是处理器任务调度和执行的基本单位

资源开销:每个进程都有独立的代码和数据空间(程序上下文),程序之间的切换会有较大的开销;线程可以看做轻量级的进程,同一类线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器(PC),线程之间切换的开销小。

包含关系:如果一个进程内有多个线程,则执行过程不是一条线的,而是多条线(线程)共同完成的;线程是进程的一部分,所以线程也被称为轻权进程或者轻量级进程。

内存分配:同一进程的线程共享本进程的地址空间和资源,而进程之间的地址空间和资源是相互独立的

影响关系:一个进程崩溃后,在保护模式下不会对其他进程产生影响,但是一个线程崩溃整个进程都死掉。所以多进程要比多线程健壮。

执行过程:每个独立的进程有程序运行的入口. 顺序执行序列和程序出口。但是线程不能独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制,两者均可并发执行

什么是上下文切换

线程的运行条件和状态称为上下文,当CPU在线程中切换时,就要保存当前线程的上下文,然后加载下一个线程的上下文,这就是线程的上下文切换

什么是协程(适合IO密集)

协程是一种用户态的轻量级线程是为并发而生的

协程拥有自己的寄存器(又称缓存,可以用来暂存指令或数据、位址)上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈。

协程是运行在线程上的,你可以创建多个协程,这些协程跑在主线程上,它们和线程的关系是一对多。

img

创建线程的三种方式对比

1)采用实现Runnable或Callable接口的方式创建多线程。

优势是

线程类只是实现了Runnable接口或Callable接口,还可以继承其他类。

在这种方式下,多个线程可以共享同一个target对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将CPU 代码和数据分开,形成清晰的模型,较好地体现了面向对象的思想。

劣势是:

编程稍微复杂,如果要访问当前线程,则必须使用Thread.currentThread()方法。

Runnable和Callable的区别

  • Callable规定(重写)的方法是call(),Runnable规定(重写)的方法是run()。
  • Callable的任务执行后可返回值,而Runnable的任务是不能返回值的。
  • call()方法可以抛出异常,run()方法不可以。
  • 运行Callable任务可以拿到一个Future对象,表示异步计算的结果。它提供了检查计算是否完成的方法,以等待计算的完成,并检索计算的结果。通过Future对象可以了解任务执行情况,可取消任务的执行,还可获取执行结果。

2)使用继承Thread类的方式创建多线程

优势是:

编写简单,如果需要访问当前线程,则无需使用Thread.currentThread()方法,直接使用this即可获得当前线程。

劣势是:

线程类已经继承了Thread类,所以不能再继承其他父类。

何如理解线程安全和不安全

线程安全是指在多线程场景下,不同线程访问同一数据能否保证其描述的正确性和一致性

如何实现线程安全

  • 互斥同步:synchronized和ReentrantLock
  • 非阻塞同步:CAS,原子操作类
  • 无同步:栈封闭,例如方法中的局部变量;线程本地存储,将数据的可见范围限制在一个线程内部,例如ThreadLocal

线程的生命周期及五种基本状态

Java线程具有五中基本状态

1)新建状态(New) :当线程对象对创建后,即进入了新建状态,如:Thread t = new MyThread();

2)就绪状态(Runnable) :当调用线程对象的start()方法(t.start() ;),线程即进入就绪状态。处于就绪状态的线程,只是说明此线程已经做好了准备,随时等待CPU调度执行,并不是说执行了t.start()此线程立即就会执行;

3)运行状态(Running) :当CPU开始调度处于就绪状态的线程时,此时线程才得以真正执行,即进入到运行状态。注:就绪状态是进入到运行状态的唯一入口,也就是说,线程要想进入运行状态执行,首先必须处于就绪状态中;

4)阻塞状态(Blocked) :处于运行状态中的线程由于某种原因,暂时放弃对CPU的使用权,停止执行,此时进入阻塞状态,直到其进入到就绪状态,才有机会再次被CPU调用以进入到运行状态。根据阻塞产生的原因不同,阻塞状态又可以分为三种:

  • 等待阻塞:运行状态中的线程执行wait()方法,使本线程进入到等待阻塞状态;
  • 同步阻塞 — 线程在获取synchronized同步锁失败(因为锁被其它线程所占用),它会进入同步阻塞状态;
  • 其他阻塞 — 通过调用线程的sleep()或join()或发出了I/O请求时,线程会进入到阻塞状态。当sleep()状态超时. join()等待线程终止或者超时. 或者I/O处理完毕时,线程重新转入就绪状态。

5)死亡状态(Dead) :线程执行完了或者因异常退出了run()方法,该线程结束生命周期。

img

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

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

关于sleep() 方法和wait()方法

区别:

  • sleep方法:是Thread类的静态方法,当前线程将睡眠n毫秒,线程进入阻塞状态。当睡眠时间到了,会解除阻塞,进入可运行状态,等待CPU的到来。睡眠不释放锁(如果有的话)。
  • wait方法:是Object的方法,必须与synchronized关键字一起使用,线程进入阻塞状态,当notify()方法或者notifyAll()方法被调用后,会解除阻塞。但是,只有重新占用互斥锁之后才会进入可运行状态。睡眠时,会释放互斥锁。
  • sleep()方法 通常被用于暂停执行,wait()方法通常被用于线程间交互/通信
  • sleep()方法执行完成后,线程会自动苏醒。wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法

相同: 都可以暂停线程的执行

关于yield()方法

yield()方法可以暂停当前正在执行的线程对象,让其它有相同优先级的线程执行。它是一个静态方法而且只保证当前线程放弃占用CPU而不能保证使其它线程一定能占用CPU,执行yield()的线程有可能在进入到暂停状态后马上又被执行。

中断线程的几种方式

  • 调用interrupt()方法,可以中断处于阻塞、无限等待、期限等待状态中的线程,抛出异常从而中断线程
  • 调用interruptd()方法可以判断线程是否被中断,从而进行中断逻辑处理
  • 调用线程池的shutdown()方法,等待所有线程执行完后关闭线程池;调用shutdownNow()方法,相当于调用所有线程的interrupt方法

守护线程是什么

守护线程是运行在后台的一种特殊进程。他独立于控制终端并周期性地执行某种任务或等待处理某些发生的事件

Java中的垃圾回收线程就是特殊的守护线程。

什么是线程死锁?如何避免?

死锁: 多个线程同时被阻塞,他们中的一个或全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。

img

造成死锁必须具备以下四个条件:

  • 互斥条件: 该资源任意一个时刻只由一个线程占用。
  • 请求与保持条件: 一个线程因请求资源而阻塞时,对已获得的资源保持不放。
  • 不可剥夺条件: 线程已获得的资源在末使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
  • 循环等待条件: 若干线程之间形成一种头尾相接的循环等待资源关系。

如何避免死锁:

只要破坏产生死锁的四个条件中的其中一个就可以了

  • 破坏请求与保持条件: 一次性申请所有的资源。
  • 破坏不可剥夺条件: 占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放已占有的资源。
  • 破坏循环等待条件: 按某一顺序申请资源,释放资源则反序释放。
  • 锁排序法: (必须回答出来的点) 通过指定锁的获取顺序,比如规定,只有获得A锁的线程才有资格获取B锁,同时获得A锁和B锁, 才能对某资源进行操作。按顺序获取锁就可以避免死锁。这通常被认为是解决死锁很好的一种方法。
  • 使用显式锁中的ReentrantLock.try(long,TimeUnit)来申请锁

公平锁和非公平锁的区别

  • 公平锁:获取锁时按照申请顺序来,先申请的先获得锁,性能较差
  • 非公平锁:获取锁时随机或者按优先级来,性能较好,但有可能出现有的线程永远拿不到锁的情况

什么是可重入锁

也称递归锁,指的是在一个线程中可以多次获取同一把锁。

比如: 一个线程在执行一个带锁的方法,该方法中又调用了另一个需要相同锁的方法,则该线程可以直接执行调用的方法,而无需重新获得锁, 两者都是同一个线程每进入一次,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。

乐观锁和悲观锁

  • 悲观锁:认为访问资源时总是会有其他线程进行修改,会将资源进行加锁,适用于写操作较多的场景,例如synchronized
  • 乐观锁:认为访问资源时不会出现问题,在执行结束前进行验证,如果验证通过则修改成功否则失败重试,适用于写操作较少的场景,例如CAS

如何实现乐观锁

  • 版本号:在修改时会比对版本号是否一致,不一致则失败
  • CAS:比较与交换,判断内存某个位置的值是否为预期值,如果是则更改为新的值,否则不进行任何操作。这个过程是原子的。

CAS了解吗

CAS 即比较并替换(Compare And Swap),是实现并发算法时常用到的一种技术。

CAS 操作包含三个操作数——内存位置、预期原值及新值。执行 CAS 操作的时候,将内存位置的值与预期原值比较,如果相匹配,那么处理器会自动将该位置值更新为新值,否则,处理器不做任何操作。

CAS 是一条 CPU 的原子指令(cmpxchg 指令),不会造成所谓的数据不一致问题,Unsafe 提供的 CAS 方法(如 compareAndSwapXXX)底层实现即为 CPU 指令 cmpxchg

CAS存在什么问题

  • ABA问题: 并发环境下,假设初始条件是A,去修改数据时,发现是A就会执行修改。但是看到的虽然是A,中间可能发生了A变B,B又变回A的情况。此时A已经非彼A,数据即使成功修改,也可能有问题

    • 可以通过AtomicStampedReference解决ABA问题,这是一个带有标记的原子引用类,通过控制变量值的版本来保证CAS的正确性。
  • 失败重试开销大: CAS 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给 CPU 带来非常大的执行开销。

  • 只能保证一个共享变量的原子操作: CAS 保证的是对一个变量执行操作的原子性,如果对多个变量操作时,CAS 目前无法直接保证操作的原子性的

    • 可以通过使用互斥锁来保证原子性
    • 将多个变量封装成对象,通过AtomicReference来保证原子性

什么是happens-before原则

如果一个操作happens-before于另一个操作,那么第一个操作的执行结果对第二个操作是可见的

happens-before 常见规则有哪些

  • 程序顺序原则:书写在前面的代码happens-before书写在后面的代码
  • volatile变量原则:volatile修饰的变量写操作happens-before读操作
  • 传递原则:A happens-before B,B happens-before C,那么A happens-before C
  • 解锁原则:解锁happens-before加锁
  • 线程启动规则:start() happens-before 线程内操作

并发关键字

volatile关键字

  • 可见性:将变量声明为volatile,在java中表示这个变量是共享且不稳定的(可理解为多线程环境下使用volatile修饰的变量的值一定是最新的
  • 禁止指令重排:volatile可以禁止JVM指令重排,在读写变量时会插入特定的内存屏障来实现(volatile的原理
  • 仅保证单次读写操作的原子性

注: 指令重排是CPU和编译器为了提升程序执行的效率,会按照一定的规则允许进行指令优化,在某些情况下,这种优化会带来一些执行的逻辑问题,主要的原因是代码逻辑之间是存在一定的先后顺序,在并发执行情况下,会发生二义性,即按照不同的执行逻辑,会得到不同的结果信息。

synchronized 是什么?有什么用?

同步锁,被它修饰的方法和代码块在同一时刻只能有一个线程执行

  • 修饰实例方法:给当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁
  • 修饰静态方法:给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁
  • 修饰代码块:对括号里指定的对象/类加锁

构造方法无法被修饰,因为构造方法本身就是线程安全的

synchronized底层原理

每个对象都会关联一个monitor监视器对象,monitor监视器对象在同一时间只能被一个线程所获取,内部有一个计数器,当计数器为0时表示锁可以被获取;线程获取锁或者重入锁时都会使计数器+1,释放锁时就减1,当减到0就表示这个锁被释放了可以被其他线程获取

synchronized性能较差有什么优化方法

由于monitor对象实现依赖于操作系统的互斥量,每次竞争锁都要切换到内核态,开销较大。在jdk1.6引入了许多针对synchronized的优化:

  • 偏向锁: 适用于单线程即没有锁竞争的场景,减少不必要的CAS操作
  • 轻量级锁: 轻量级锁是由偏向锁升级而来,当存在第二个线程申请同一个锁对象时,偏向锁就会立即升级为轻量级锁。注意这里的第二个线程只是申请锁,不存在两个线程同时竞争锁,可以是一前一后地交替执行同步块。
  • 锁消除: 在JIT编译时,对运行上下文进行扫描,去除不可能存在竞争的锁。
  • 锁粗化: 通过扩大锁的范围,避免反复加锁和释放锁
  • 适应性自旋锁: 当CAS获取轻量级锁失败时,会进行一定的自旋重试,到达一定失败次数后才升级为重量级锁

注: 锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。参考Java6及以上版本对synchronized的优化 - 蜗牛大师 - 博客园 (cnblogs.com)

自旋锁的定义: 当一个线程尝试去获取某一把锁的时候,如果这个锁此时已经被别人获取(占用),那么此线程就无法获取到这把锁,该线程将会等待,间隔一段时间后会再次尝试获取。这种采用循环加锁 -> 等待的机制被称为自旋锁(spinlock)

自旋锁的原理比较简单,如果持有锁的线程能在短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞状态,它们只需要等一等(自旋),等到持有锁的线程释放锁之后即可获取,这样就避免了用户进程和内核切换的消耗。

Synchronized在使用时有何注意事项

  • 范围不要太大,影响性能
  • 锁对象不能为空
  • 避免死锁
  • 优先选择并发集合来应对多线程
  • 抛出异常会自动释放锁

synchronized 和 volatile 有什么区别?

  • volatile 解决的是内存可见性问题,会使得所有对 volatile 变量的读写都直接写入主存,即 保证了变量的可见性
  • synchronized 解决的是执行控制的问题,它会阻止其他线程获取当前对象的监控锁,这样一来就让当前对象中被 synchronized 关键字保护的代码块无法被其他线程访问,也就是无法并发执行。而且,synchronized 还会创建一个 内存屏障,内存屏障指令保证了所有 CPU 操作结果都会直接刷到主存中,从而 保证操作的内存可见性,同时也使得这个锁的线程的所有操作都 happens-before 于随后获得这个锁的线程的操作。

两者的区别主要有如下:

  1. volatile 本质是在告诉 JVM 当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取; synchronized 则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
  2. volatile 仅能使用在变量级别;synchronized 则可以使用在 变量. 方法. 和类级别
  3. volatile 仅能实现变量的修改可见性,不能保证原子性;而synchronized 则可以 保证变量的修改可见性和原子性
  4. volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞
  5. volatile 标记的变量不会被编译器优化;synchronized 标记的变量可以被编译器优化。

synchronized和Lock有什么区别?

  • synchronized 可以给类. 方法. 代码块加锁;而 lock 只能给代码块加锁。
  • synchronized 不需要手动获取锁和释放锁,使用简单,发生异常会自动释放锁,不会造成死锁;而 lock 需要自己加锁和释放锁,如果使用不当没有 unLock()去释放锁就会造成死锁。
  • 通过 Lock 可以知道有没有成功获取锁,而 synchronized 却无法办到。

synchronized 和 ReentrantLock 有什么区别

  • 两者都是可重入锁

  • synchronized依赖于JVM,许多优化都是在JVM层面进行的;ReentrantLock实现于API层面

  • ReentrantLock具有许多高级功能:

    • 等待可中断:提供一个方法,可以在等待获取锁时中断等待,去做别的事情
    • 可实现公平锁:默认非公平锁,可以支持非公平锁
    • 可以绑定多个通知条件(Condition)
  • 使用选择

    • 除非需要使用 ReentrantLock 的高级功能,否则优先使用 synchronized。
    • synchronized 是 JVM 实现的一种锁机制,JVM 原生地支持它,而 ReentrantLock 不是所有的 JDK 版本都支持。并且使用 synchronized 不用担心没有释放锁而导致死锁问题,因为 JVM 会确保锁的释放

ThreadLocal 是什么?【没懂】

ThreadLocal,即线程本地变量。如果你创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的一个本地拷贝,多个线程操作这个变量的时候,实际是操作自己本地内存里面的变量,从而起到线程隔离的作用,避免了线程安全问题。

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

ThreadLocal的应用场景有

  • 数据库连接池
  • 会话管理中使用

线程池

为什么要使用线程池

线程池提供了一种限制和管理资源(包括执行一个任务)的方法。 每个线程池还维护一些基本统计信息,例如已完成任务的数量。

使用线程池的好处:

  • 降低资源消耗。 通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
  • 提高响应速度。 当任务到达时,任务可以不需要的等到线程创建就能立即执行。
  • 提高线程的可管理性。 线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

线程池核心参数

  • corePoolSize: 核心线程大小。线程池一直运行,核心线程就不会停止。

  • maximumPoolSize:任务队列中存放的任务达到队列容量的时候,当前可运行的线程数量变为最大线程数。

  • workQueue:阻塞队列。新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。

  • keepAliveTime :非核心线程的心跳时间。如果非核心线程在keepAliveTime内没有运行任务,非核心线程会消亡。

  • defaultHandler :饱和策略。如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时,ThreadPoolTaskExecutor 定义一些策略:

    • AbortPolicy : 将新的任务丢弃并报错。默认饱和策略。
    • DiscardPolicy : 将新的任务直接丢弃不报错。
    • DiscardOldestPolicy : 将workQueue队首任务丢弃,将最新线程任务重新加入队列执行。
    • CallerRunsPolicy :调度任务的线程自己调用run方法执行。该策略保证任务不会被丢弃,但是可能会导致任务执行的时间变长
  • ThreadFactory :线程工厂。新建线程工厂。

 /**
  * 用给定的初始参数创建一个新的ThreadPoolExecutor。
  */
 public ThreadPoolExecutor(int corePoolSize,//线程池的核心线程数量
                           int maximumPoolSize,//线程池的最大线程数
                           long keepAliveTime,//当线程数大于核心线程数时,多余的空闲线程存活的最长时间
                           TimeUnit unit,//时间单位
                           BlockingQueue<Runnable> workQueue,//任务队列,用来储存等待执行任务的队列
                           ThreadFactory threadFactory,//线程工厂,用来创建线程,一般默认即可
                           RejectedExecutionHandler handler//拒绝策略,当提交的任务过多而不能及时处理时,我们可以定制策略来处理任务
                            ) {
     if (corePoolSize < 0 ||
         maximumPoolSize <= 0 ||
         maximumPoolSize < corePoolSize ||
         keepAliveTime < 0)
         throw new IllegalArgumentException();
     if (workQueue == null || threadFactory == null || handler == null)
         throw new NullPointerException();
     this.corePoolSize = corePoolSize;
     this.maximumPoolSize = maximumPoolSize;
     this.workQueue = workQueue;
     this.keepAliveTime = unit.toNanos(keepAliveTime);
     this.threadFactory = threadFactory;
     this.handler = handler;
 }

执行execute()方法和submit()方法的区别是什么?

  • execute() 方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否;
  • submit() 方法用于提交需要返回值的任务。线程池会返回一个future类型的对象,通过这个future对象可以判断任务是否执行成功,并且可以通过future的get()方法来获取返回值,get()方法会阻塞当前线程直到任务完成,而使用 get(long timeout,TimeUnit unit)方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完。

线程池执行任务的流程?

  1. 线程池执行execute/submit方法向线程池添加任务,当任务小于核心线程数corePoolSize,线程池中可以创建新的线程。
  2. 当任务大于核心线程数corePoolSize,就向阻塞队列添加任务。
  3. 如果阻塞队列已满,需要通过比较参数maximumPoolSize,在线程池创建新的线程,当线程数量大于maximumPoolSize,说明当前设置线程池中线程已经处理不了了,就会执行饱和策略。
image.png