Java并发编程

186 阅读20分钟

1 线程基础知识

1.1 启动线程的方式

2种,Thread类中的注释明确说明启动线程有两种方式:

  1. extends Thread

    Thread.satrt

  2. runable

    new Thread(runable).start();

callable
callable启动线程的本质是:callable包装成FutureTask创建线程,而FutureTask实现了RunnableFuture接口,RunnableFuture继承自Runable,所以,callable启动线程,本质上通过Runable启动的。

new Thread(new FutureTask<>(callable)).start();

FutureTask.get() //获取返回值

1.2线程的状态/生命周期

线程状态.png

线程只有被synchronized修饰,未获取到锁的时候,进入阻塞态。

1.3 run() start()区别

run方法就是普通对象的普通方法,不会启动线程;

只有调用了start()后,Java才会将线程对象和操作系统中实际的线程进行映射,再来执行run方法。

1.4 线程停止的方法

  • stop:stop会强制停止线程,不能保存线程中资源的状态,所以不能调用stop停止线程。
  • interrupt:只是协作式的方式,并不能绝对保证中断,并不是抢占式的。
  • 正确的方式:通过标志位,结束run方法中的循环。

1.5 守护线程

Thread.setDaemon(true); // Thread设置成了守护线程

主线程结束时,守护线程结束。

1.6 怎样保证线程的执行顺序

执行线程的join方法。

线程A,执行了线程B的join方法,线程A必须要等待B执行完成了以后,线程A才能继续自己的工作。

1.7 多线程中的并行和并发是什么?

  • 并行:同一时刻,同时执行的任务数。
  • 并发:单位时间内,执行的任务量(吞吐量)。

1.8 在Java中能不能指定CPU去执行某个线程?

不能,Java是做不到的,唯一能够去干预的就是C语言调用内核的API去指定才行

1.9 在项目开发过程中,你会考虑Java线程优先级吗?

不会考虑优先级,因为线程的优先级很依赖与系统的平台,导致这个优先级无法对号入座,无法做到你想象中的优先级,属于不稳定,有风险。

例如:Java线程优先级有十级,而此时操作系统优先级只有2~3级,那么就对应不上了。

1.10 sleep和wait有什么区别?

1、sleep是线程中的静态方法,但是wait是Object中的方法。

2、sleep方法不会释放lock,但是wait会释放,而且会加入到等待队列中。

3、sleep方法不依赖于synchronized,可用在任何地方,wait,notify和notifyAll只能用在synchronized控制方法或代码块中。

4、sleep不需要被唤醒(休眠结束后退出等待状态,进入就绪状态),但是wait需要被其他线程唤醒后,进入就绪状态。

1.11 sleep,wait,哪个函数会清除中断标记?

sleep在抛出异常的时候,捕获异常之前,就已经清除。

1.12 如何让出当前线程的执行权?

yield方法,只在JDK某些实现才能看到,是让出执行权

1.13 死锁

1.13.1 概念

是指两个或两个以上的线程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁。

1.13.2 死锁的必然条件

  1. 多个操作者,竞争多个资源,并且资源数小于等于操作者数。
  2. 争夺资源的顺序不对。
  3. 操作者拿到资源后不放手。

1.13.3 死锁的解决方法

  1. 内部通过顺序比价,确定拿锁顺序,确保拿锁顺序一致。
  2. 采用尝试拿锁的机制 lock.tryLock,如果拿不到,释放已拿到资源。

1.14 活锁

两个线程在尝试拿锁的机制中,发生多个线程之间互相谦让,不断发生同一个线程总是拿到同一把锁,在尝试拿另一把锁时因为拿不到,而将本来已经持有的锁释放的过程。

解决方法:每个线程休眠随机数,错开拿锁的时间。

1.15 线程饥饿

低优先级的线程,总是拿不到执行时间。

1.16 ThreadLocal

实现一个线程本地的存储,也就是说,每个线程都有自己的局部变量,属于线程自身所有,不在多个线程间共享。不会和其他线程的变量冲突,实现了线程的数据隔离。所有线程都共享一个ThreadLocal对象,但是每个线程在访问这些变量的时候能得到不同的值,每个线程可以更改这些变量并且不会影响其他的线程,并且支持null值。

ThreadLocal有一个静态内部类ThreadLocalMap,ThreadLocalMap使用Entry[]存储数据,Entry为一键值对<key(ThreadLocal), value>。

每个Thread都持有一个ThreadLocalMap的对象,

ThreadLocal提供set、get、remove等方法,允许本Thread内不同的ThreadLocal操作ThreadLocalMap的Entry[]。

数据结构:

threadLocal数据结构.png

存在两个线程Thread-1、Thread-2,以及两个ThreadLocal:threadLocal、threadLocal2。 Thread-1:

    threadLocal.set("线程-1");
    threadLocal2.set(1);

Thread-2:

    threadLocal.set("线程-2");

线程结束时,应调用remove,删除线程局部变量的值,以减少内存占用。

2 CAS (Comper And Swap)基本原理

2.1 什么是原子操作?怎么实现原子操作?

  • 原子操作

    不可中断的一个或者一系列操作, 也就是不会被线程调度机制打断的操作, 运行期间不会有任何的上下文切换。

  • 怎么实现原子操作

    使用锁或者CAS可以实现原子操作。

    锁实现的原子操作存在的问题:

    使用锁实现的原子操作比较耗费资源,synchronized关键字是基于阻塞的锁机制,当一个线程使用锁的时候,其他线程需要等待。存在的具体问题:

    1. 被阻塞的线程优先级很高。
    2. 获得锁的线程一直不释放锁。
    3. 大量线程竞争资源,CPU会花费大量的资源和时间处理这些竞争(上下文切换)。

2.2 CAS原理

CAS操作过程都包含三个运算符:一个内存地址V,一个期望的值A和一个新值B,操作的时候如果这V地址上存放的值等于这个期望的值A,则将地址上的值赋为新值B,否则不做任何操作。

CAS的基本思路就是,如果这个地址上的值和期望的值相等,则给其赋予新值,否则不做任何事儿,但是要返回原值是多少。循环CAS就是在一个循环里不断的做cas操作,直到成功为止。

自旋即循环。

CAS.png

2.3 CAS问题

  1. ABA问题

    CAS需要在操作值的时候,检查值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。

    • 解决

      ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加1,那么A→B→A就会变成1A→2B→3A。检测值的时候,同时检测版本号,只有版本号和值都相等,才认为值没有发生变化。

  2. 开销问题

    CAS如果长时间不成功,会给CPU带来非常大的执行开销

  3. 只能保证一个共享变量的原子操作

    当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性。

    • 解决
    1. 可以用锁解决。
    2. 也可把多个共享变量封装成对象,使用AtomicReference类来保证引用对象之间的原子性,把多个变量放在一个对象里来进行CAS操作。

2.4 悲观锁、乐观锁

  • 悲观锁:具有强烈的独占和排他特性。它对数据被外界修改持保守的态度(总有刁民要害朕)因此,在整个数据处理过程中,将数据处于锁定状态。
  • 乐观锁:乐观锁是相对悲观锁而言的,乐观锁假设数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,再次循环CAS指令。

乐观锁比悲观锁效率会高,因为悲观锁涉及到上下文切换,上下文切换比较耗费时间和资源。

3 阻塞队列和线程池原理

3.1 阻塞对列

3.1.1 对列

先进先出(FIFO:first in first out)的一种线性表,只允许在表的前端(front)进行删除操作(出队),而在表的后端(rear)进行插入操作(入队)。

3.1.2 阻塞队列(BlockingQueue)

有两种情况会阻塞:

  1. 对列满的时候,往队列中放元素的动作会被阻塞。
  2. 队列为空时,从队列中取元素的动作会被阻塞。 阻塞队列可用于,生产者和消费者模式中,可达到生产者和消费者解耦,平衡消费者生产者的性能均衡问题。

3.1.3 常用阻塞队列

  • ArrayBlockingQueue:一个由数组结构组成的有界阻塞队列。
  • LinkedBlockingQueue:一个由链表结构组成的有界阻塞队列。
  • PriorityBlockingQueue:一个支持优先级排序的无界阻塞队列。
  • DelayQueue:一个使用优先级队列实现的无界阻塞队列。
  • SynchronousQueue:一个不存储元素的阻塞队列。
  • LinkedTransferQueue:一个由链表结构组成的无界阻塞队列。
  • LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列。

3.2 线程池

3.2.1 什么是线程池

线程池就是提前创建若干个线程放到一个容器中,需要的时候从容器中获取线程不用自行创建,使用完毕后不需要销毁线程而是放回到容器中,从而减少创建和销毁线程对象的开销。

3.2.2 为什么要用线程池

  1. 降低源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
  2. 提高响应速度。线程的创建时间为T1,运行时间为T2,销毁时间为T3,复用线程池中的线程能降低T1+T2的时间。
  3. 提高线程的可管理性。线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一分配、调优和监控。

3.2.3 JDK中的线程池和工作机制

3.2.3.1 线程池创建

各参数含义:

  1. int corePoolSize:

    线程池中核心线程数, 向线程池提交任务时,当前线程数< corePoolSize,创建新线程;当前线程数=corePoolSize,这个任务就会保存到BlockingQueue。调用preStartAllCoreThreads()就会一次性的启动corePoolSize个线程。

  2. int maximumPoolSize:允许的最大线程数,BlockingQueue满了,继续提交任务,当前线程数小于maximumPoolSize的时候,就会再次创建新的线程。

  3. log keepAliveTime:线程空闲时的存活时间,即当线程没有任务执行时,继续存活的时间。默认在任务数大于corePoolSize时才有效。

  4. TimeUnit unit:存活时间单位。

  5. BlockingQueue workQueue:保存任务的阻塞队列,当线程池中的线程数超过它的corePoolSize的时候,任务会进入阻塞队列进行阻塞等待。通过workQueue,线程池实现了阻塞功能。workQueue尽量使用有界队列,否则corePoolSize、maximumPoolSize将无效,更重要的是,无界队列可能会耗尽系统资源。

  6. ThreadFacktory threadFactory:创建线程的工厂,通过自定义的线程工厂可以给每个新建的线程设置一个具有识别度的线程名,当然还可以更加自由的对线程做更多的设置,比如设置所有的线程为守护线程。

  7. RejectExecutionHandler handler:线程池的饱和策略,当阻塞队列满了,且没有空闲的工作线程,如果继续提交任务,必须采取一种策略处理该任务,线程池提供了4种策略:

    • AbrotPolicy:直接抛出异常,默认。
    • CallerRunsPolicy:用调用者所在的线程执行任务。
    • DiscardOldestPolicy:丢弃阻塞队列里最老的任务。
    • DiscardPolicy:当前任务直接丢弃。

    当然也可以根据应用场景实现RejectedExecutionHandler接口,自定义饱和策略。

3.2.3.2 提交任务

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

3.2.3.3 关闭线程池

可以通过调用线程池的shutdown或shutdownNow方法来关闭线程池。它们的原理是遍历线程池中的工作线程,然后逐个调用线程的interrupt方法来中断线程,所以无法响应中断的任务可能永远无法终止。

3.2.3.4 线程池状态

08000847-0a9caed4d6914485b2f56048c668251a.jpg

3.2.3.4 线程池工作机制

线程池机制.png

3.2.3.4 合理地配置线程池

主要配置最大线程数大小和阻塞队列。

要想合理地配置线程池,就必须首先分析任务特性。

  • 最大线程数大小

    任务特性分为:

    1. CPU(计算)密集型:最大线程数适当小一点,最大推荐值:CPU核心数+1(加1的原因:一部分磁盘会虚拟内存,防止页缺失,导致CPU空闲)
    2. IO密集型:最大线程数适当大一点,CPU核心数*2,IO操作包括读写磁盘、网络等,速度远小于内存读写速度。
    3. 混合型:当IO密集型任务执行时间与计算密集型相近,拆分成两个线程池,否则视为CPU密集型。 可以通过Runtime.getRuntime().availableProcessors()方法获得当前设备的CPU个数
  • 阻塞队列

    workQueue尽量使用有界队列,否则corePoolSize、maximumPoolSize将无效,更重要的是,无界队列可能会耗尽系统资源。

3.2.3.5 预定义线程池

  1. newFixedThreadPool 定长线程池

    一个有指定的线程数的线程池,有核心的线程,里面有固定的线程数量,响应的速度快。正规的并发线程,多用于服务器。固定的线程数由系统资源设置。核心线程是没有超时机制的,队列大小没有限制,除非线程池关闭了核心线程才会被回收。

  2. newCachedThreadPool 可缓冲线程池

    只有非核心线程,最大线程数很大,每新来一个任务,当没有空余线程的时候就会重新创建一个线程,这边有一个超时机制,当空闲的线程超过60s内没有用到的话,就会被回收,它可以一定程序减少频繁创建/销毁线程,减少系统开销,适用于执行时间短并且数量多的任务场景。

  3. ScheduledThreadPool 周期线程池

    创建一个定长线程池,支持定时及周期性任务执行,通过过schedule方法可以设置任务的周期执行

  4. newSingleThreadExecutor 单任务线程池

    创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行,每次任务到来后都会进入阻塞队列,然后按指定顺序执行。

4 深入理解并发编程

4.1 AQS原理

AQS是队列同步器,是用来构建锁或者其他同步组件的基础框架。比如Reentrant、信号量等就是基于AQS实现的。

  1. 其内部使用一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作。
  2. AQS的主要使用方式是继承,子类通过继承AQS并实现它的抽象方法来管理同步状态,同步器的设计基于模板方法模式,所以如果要实现我们自己的同步工具类就需要覆盖其中几个可重写的方法,如tryAcquire、tryRelease等,这些方法中主要工作是对state变量做相关的修改,以记录管理同步状态。
  3. AQS基本思想是CLH队列锁,当一个线程需要获取锁时,打包成一个节点,挂到一个链表上去,所有需要获取锁的线程,形成一个链表,每一个线程不断检测前一个节点的线程是都释放了锁,如果释放了锁,当前线程便可获取锁。

4.3 Java内存模型(JMM)

从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。

4.2 volatile

volatile变量具有可见性和原子性。原子性和可见性仅对单个volatile变量的读写有效,但volatile++这种符合操作不具备原子性。

volatile用于一个线程写,多个线程读的场景。

线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量,那么对于共享变量V,它们首先是在自己的工作内存,之后再同步到主内存。可是并不会及时的刷到主存中,而是会有一定时间差。很明显,这个时候线程 A 对变量 V 的操作对于线程 B 而言就不具备可见性了 。

要解决共享对象可见性这个问题,我们可以使用volatile关键字或者是加锁。

volatile实现原理: volatile关键字修饰的变量会存在一个“lock:”的前缀。 Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。 同时该指令会将当前处理器缓存行的数据直接写会到系统内存中,且这个写回内存的操作会使在其他CPU里缓存了该地址的数据无效。

5 知识点总结

  1. sychronied修饰普通方法和静态方法的区别?什么是可见性?

    • sychronied修饰普通方法:对象锁,用于对象实例方法,或者一个对象实例上。
    • 静态方法:类锁,锁的是类对应的class对象。 可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
  2. 锁分哪几类

  3. CAS无锁编程的原理

    使用当前的处理器基本都支持CAS()的指令,CAS操作过程都包含三个运算符:一个内存地址V,一个期望的值A和一个新值B,操作的时候如果这V地址上存放的值等于这个期望的值A,则将地址上的值赋为新值B,否则不做任何操作。

    CAS的基本思路就是,如果这个地址上的值和期望的值相等,则给其赋予新值,否则不做任何事儿,但是要返回原值是多少。循环CAS就是在一个循环里不断的做cas操作,直到成功为止。

  4. ReentrantLock的实现原理

    线程可以重复进入任何一个它已经拥有的锁所同步着的代码块,synchronized、ReentrantLock都是可重入的锁。在实现上,就是线程每次获取锁时判定如果获得锁的线程是它自己时,简单将计数器累积即可,每释放一次锁,进行计数器累减,直到计算器归零,表示线程已经彻底释放锁。

    底层则是利用了JUC中的AQS来实现的。

  5. AQS原理

    AQS是队列同步器,是用来构建锁或者其他同步组件的基础框架。比如Reentrant、信号量等就是基于AQS实现的。

    1. 其内部使用一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作。
    2. AQS的主要使用方式是继承,子类通过继承AQS并实现它的抽象方法来管理同步状态,同步器的设计基于模板方法模式,所以如果要实现我们自己的同步工具类就需要覆盖其中几个可重写的方法,如tryAcquire、tryRelease等,这些方法中主要工作是对state变量做相关的修改,以记录管理同步状态。
    3. AQS基本思想是CLH队列锁,当一个线程需要获取锁时,打包成一个节点,挂到一个链表上去,所有需要获取锁的线程,形成一个链表,每一个线程不断检测前一个节点的线程是都释放了锁,如果释放了锁,当前线程便可获取锁。
  6. Synchronized的原理以及与ReentrantLock的区别

  • Synchronized的原理

    Synchronized是使用monitorenter和monitorexit指令实现的。

    • 同步代码块

      同步代码块反编译后,可看到monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到同步代码块结束处和异常处。

      当JVM访问到monitorenter指令时,会获取与monitorenter相关的monitor,获取成功后,执行同步代码块,退出同步代码块就是通过monitorexit释放对monitor对象的占有。

    • 同步方法

      从同步方法反编译的结果来看,方法的同步并没有通过指令monitorenter和monitorexit来实现,相对于普通方法,其常量池中多了ACC_SYNCHRONIZED标示符。

      JVM就是根据该标示符来实现方法的同步的:当方法被调用时,调用monitorenter指令检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。

  • 区别

    1. Synchronized是关键字,JVM级别的,是内置锁,ReentrantLock是对象,是显示锁。
    2. ReentrantLock提供了一些Synchronized锁不具备的功能:拿锁过程可中断,可尝试拿锁。
    3. Synchronized是非公平锁,ReentrantLock除了是非公平锁外还提供了公平锁。
  1. Synchronized做了哪些优化

    为了提升性能,JDK引入了自旋锁,适应性自旋锁、轻量级锁、锁消除、锁粗化、偏向锁、等技术来减少操作的开销,消除无意义的锁获取和释放,可以提高程序运行性能。

    • 锁消除:虚拟机的运行时编译器在运行时如果检测到一些要求同步的代码上不可能发生共享数据竞争,则会去掉这些锁
    • 锁粗化:将临近的代码块用同一个锁合并起来,减少上下文切。
  2. volatile 能否保证线程安全?在DCL上的作用是什么?

    不能保证,在DCL的作用是:volatile是会保证被修饰的变量的可见性和 有序性,保证了单例模式下,保证在创建对象的时候的执行顺序一定是

    1. 分配内存空间
    2. 实例化对象instance
    3. 把instance引用指向已分配的内存空间,此时instance有了内存地址,不再为null了的步骤。 从而保证了instance要么为null 要么是已经完全初始化好的对象。

    DCL:双重检查锁(应用在单例模式)

  3. volatile和synchronize有什么区别?

  • volatile是最轻量的同步机制,保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。但是volatile不能保证操作的原子性,因此多线程下的写复合操作会导致线程安全问题。

  • 关键字synchronized可以修饰方法或者以同步块的形式来进行使用,它主要确保多个线程在同一个时刻,只能有一个线程处于方法或者同步块中,它保证了线程对变量访问的可见性和排他性,又称为内置锁机制。

  1. 什么是守护线程?你是如何退出一个线程的?

    线程的中止:要么是run执行完成了,要么是抛出了一个未处理的异常导致线程提前结束。安全的中止则是其他线程通过调用某个线程A的interrupt()方法对其进行中断操作,被中断的线程则是通过线程通过方法isInterrupted()来进行判断是否被中断。

  2. sleep 、wait、yield 的区别,wait 的线程如何唤醒它?

  • yield()方法:使当前线程让出CPU占有权,但让出的时间是不可设定的。也不会释放锁资源。所有执行yield()的线程有可能在进入到就绪状态后会被操作系统再次选中马上又被执行。
  • yield() 、sleep()被调用后,都不会释放当前线程所持有的锁。
  • 调用wait()方法后,会释放当前线程持有的锁,而且当前被唤醒后,会重新去竞争锁,锁竞争到后才会执行wait方法后面的代码。
  • Wait通常被用于线程间交互,sleep通常被用于暂停执行,yield()方法使当前线程让出CPU占有权。
  • wait 的线程使用notify/notifyAll()进行唤醒。
  1. sleep是可中断的么?

    sleep本身就支持中断,如果线程在sleep期间被中断,则会抛出一个中断异常。

  2. 线程生命周期。

  3. ThreadLocal是什么?

    ThreadLocal是Java里一种特殊的变量。ThreadLocal为每个线程都提供了变量的副本,使得每个线程在某一时间訪问到的并非同一个对象,这样就隔离了多个线程对数据的数据共享。

    在内部实现上,每个线程内部都有一个ThreadLocalMap,用来保存每个线程所拥有的变量副本。

  4. 线程池基本原理

    在开发过程中,合理地使用线程池能够带来3个好处。 第一:降低资源消耗。第二:提高响应速度。第三:提高线程的可管理性。

    1. 如果当前运行的线程少于corePoolSize,则创建新线程来执行任务(注意,执行这一步骤需要获取全局锁)。
    2. 如果运行的线程等于或多于corePoolSize,则将任务加入BlockingQueue。
    3. 如果无法将任务加入BlockingQueue(队列已满),则创建新的线程来处理任务。
    4. 如果创建新线程将使当前运行的线程超出maximumPoolSize,任务将被拒绝,并调用RejectedExecutionHandler.rejectedExecution()方法。
  5. 有三个线程T1,T2,T3,怎么确保它们按顺序执行?

    可以用join方法实现。

    T3的run方法中调用t2.join,T2的run方法中调用t1.join。