并发相关面试题

并发

参考链接

线程基础

线程和进程区别

进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。

线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享进程的方法区资源,但每个线程有自己的程序计数器虚拟机栈本地方法栈,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。

线程使用

有三种使用线程的方法:

  • 实现 Runnable 接口;
  • 实现 Callable 接口,返回值使用FutureTask封装;
  • 继承 Thread 类。

线程基础机制

Thread.sleep()

休眠当前正在进行的线程,使得线程处于timed_waiting状态,休眠线程可能被中断,线程中断时,抛出InterruptedException

Thread.yield()

使运行状态的线程让出CPU调度权,进入就绪状态,重新同其他线程竞争CPU调度权。

Thread.join()

当前线程等待子线程执行结束后才能执行

Thread.interrupt()

调用该方法中断线程,如果线程处于阻塞、限期等待或者无限期等待状态,那么就会抛出 InterruptedException,从而提前结束该线程。但是不能中断 I/O 阻塞和 synchronized 锁阻塞。

Object.wait()

使当前线程进入等待状态,必须在同步锁范围代码中使用

Object.notify()

唤醒进入等待状态的线程,必须在同步锁范围代码中使用

线程的生命周期和状态

Java 线程在运行的生命周期中的指定时刻只可能处于下面 6 种不同状态的其中一个状态:

  • NEW: 初始状态,线程被创建出来但没有被调用 start()
  • RUNNABLE: 运行状态,线程被调用了 start()等待运行的状态。
  • BLOCKED :阻塞状态,需要等待锁释放。
  • WAITING:等待状态,表示该线程需要等待其他线程做出一些特定动作(通知或中断)。
  • TIME_WAITING:超时等待状态,可以在指定的时间后自行返回而不是像 WAITING 那样一直等待。
  • TERMINATED:终止状态,表示该线程已经运行完毕。

线程在生命周期中并不是固定处于某一个状态而是随着代码的执行在不同状态之间切换。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0jLAftwI-1676986679777)(/Users/linmeng/IdeaProjects/summary/images/线程状态.png)]

由上图可以看出:线程创建之后它将处于 NEW(新建) 状态,调用 start() 方法后开始运行,线程这时候处于 READY(可运行) 状态。可运行状态的线程获得了 CPU 时间片(timeslice)后就处于 RUNNING(运行) 状态。

在操作系统层面,线程有 READY 和 RUNNING 状态;而在 JVM 层面,只能看到 RUNNABLE 状态(图源:HowToDoInJavaJava Thread Life Cycle and Thread States),所以 Java 系统一般将这两个状态统称为 RUNNABLE(运行中) 状态 。

为什么 JVM 没有区分这两种状态呢? (摘自:Java 线程运行怎么有第六种状态? - Dawell 的回答 ) 现在的时分(time-sharing)多任务(multi-task)操作系统架构通常都是用所谓的“时间分片(time quantum or time slice)”方式进行抢占式(preemptive)轮转调度(round-robin 式)。这个时间分片通常是很小的,一个线程一次最多只能在 CPU 上运行比如 10-20ms 的时间(此时处于 running 状态),也即大概只有 0.01 秒这一量级,时间片用后就要被切换下来放入调度队列的末尾等待再次调度。(也即回到 ready 状态)。线程切换的如此之快,区分这两种状态就没什么意义了。

  • 当线程执行 wait()方法之后,线程进入 WAITING(等待) 状态。进入等待状态的线程需要依靠其他线程的通知才能够返回到运行状态。
  • TIMED_WAITING(超时等待) 状态相当于在等待状态的基础上增加了超时限制,比如通过 sleep(long millis)方法或 wait(long millis)方法可以将线程置于 TIMED_WAITING 状态。当超时时间结束后,线程将会返回到 RUNNABLE 状态。
  • 当线程进入 synchronized 方法/块或者调用 wait 后(被 notify)重新进入 synchronized 方法/块,但是锁被其它线程占有,这个时候线程就会进入 BLOCKED(阻塞) 状态。
  • 线程在执行完了 run()方法之后将会进入到 TERMINATED(终止) 状态。

线程安全

ThreadLocal

线程自己的专属变量,存储各个线程的私有变量,线程安全。

互斥同步(锁机制)

乐观锁和悲观锁

悲观锁每次获取共享资源时,都假设共享资源会出问题(被修改),所以每次获取共享资源时都会上锁,从而保证共享资源只能被一个线程使用,其他线程阻塞。

乐观锁每次获取资源时,都假设共享资源不会被修改,无需加锁也无需等待,只是在提交修改时去验证资源是否被其他线程修改。

Java 中synchronizedReentrantLock等独占锁就是悲观锁思想的实现,悲观锁通常用于写比较多的情况下,避免频繁失败和重试影响性能。Java 中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式 CAS 实现的。乐观锁通常多于写比较少的情况下(多读场景),避免频繁加锁影响性能,大大提升了系统的吞吐量。

synchronized

synchronized是可重入的对象锁,主要用于解决多个线程访问的同步性,同一时刻只能有一个线程进入其修饰的代码块、方法。

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

1:修饰代码块

对括号里面指定的对象或类加锁

  • synchronized(Object):进入代码块需要获取指定的对象
  • synchronized(类.Class):进入代码块需要获取指定Class的锁

2:修饰实例方法

为当前对象实例加锁,进入方法前需要获取到当前对象实例的锁

3:修饰静态方法

为当前类加锁,进入方法前需要获取到当前类的锁

原理

底层是通过monitor的对象来完成,线程通过mointorenter指令获取monitor的所有权,通过monitorexit释放mointor的所有权。该指令是JVM调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。

锁的优化

适应自旋:线程对阻塞和唤醒对CPU的压力很大,很多情况下对象的锁状态只会持续很短时间。为了减轻CPU压力,当对象获取不到锁时,循环检测锁是否被释放,而不是进入线程挂起状态。同时,如果自旋的次数过多,也会造成不必要的损耗,自旋次数会根据上一次自旋的情况来决定,如果上一次自旋成功获取锁,这一次就多自旋几次;相反,如果很少有线程自旋成功,那么获取这个锁的自旋次数就会减少或者省略掉自旋过程。

锁消除:当我们自己的代码中加锁或者JDK的API中有加锁操作但是JVM检测并没有共享数据竞争时,JVM会将这些同步锁消除

锁粗化:当JVM检测到对同一个对象多次添加锁时,JVM会将多次锁连接成一个锁。

偏向锁:很多情况下,锁不存在多个线程竞争,而是同一线程多次获取,这时引入偏向锁,通过CAS原子指令多次获取和释放锁,降低获取锁的代价。

轻量级锁:当关闭偏向锁或者多个线程竞争偏向锁时,会将偏向锁升级为轻量级锁。轻量级锁也是通过CAS自旋获取锁的

重量级锁:轻量级锁自旋失败时,膨胀为重量级锁。

线程池

管理一系列线程的资源池。有任务需要处理时,从线程池中获取线程执行任务;任务执行完毕后,线程并不会被销毁,而是等待下一个任务的到来。

使用线程池的好处:

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

创建

方式一通过 Executor 框架的工具类 Executors 来实现 我们可以创建多种类型的 ThreadPoolExecutor

  • FixedThreadPool : 该方法返回一个固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。
  • SingleThreadExecutor 方法返回一个只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中(任务队列无限容量),待线程空闲,按先入先出的顺序执行队列中的任务。
  • CachedThreadPool 该方法返回一个可根据实际情况调整线程数量的线程池,无核心线程数,最大线程数无限。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。
  • ScheduledThreadPool:方法返回一个定时执行线程任务的线程池,指定核心线程数,最大线程数无限。
  • SingleThreadScheduledExecutor:方法返回一个定时执行线程任务的线程池,核心线程数1个,最大线程数无限。
  • WorkStealingPool:工作窃取线程池,通常将线程数指定同CPU数量相同,将大的任务拆分成多个小任务,将小任务的执行结果合并,通过工作窃取算法分配线程。

方式二:通过ThreadPoolExecutor构造函数来创建(推荐)。

Executor创建线程池缺点明显,推荐使用构造函数创建,

参数

/**
 * 用给定的初始参数创建一个新的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;
}

ThreadPoolExecutor 3 个最重要的参数:

  • corePoolSize : 任务队列未达到队列容量时,最大可以同时运行的线程数量。
  • maximumPoolSize : 任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。
  • workQueue: 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。

ThreadPoolExecutor其他常见参数 :

  • keepAliveTime:线程池中的线程数量大于 corePoolSize 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime才会被回收销毁;
  • unit : keepAliveTime 参数的时间单位。
  • threadFactory :executor 创建新线程的时候会用到。
  • handler :拒绝策略。关于拒绝策略下面单独介绍一下

工作流程

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xVIULQ6K-1676986679778)(/Users/linmeng/IdeaProjects/summary/images/线程池流程.png)]

  1. 提交线程任务,当前运行的线程数量小于核心线程数,新建一个线程执行任务
  2. 当前运行的线程数量达到核心线程数,将任务放到等待队列中
  3. 等待队列不存储任务或等待队列已满,但是当前运行的线程数量小于最大线程数,新建线程执行任务
  4. 当前运行线程数等于最大线程数,任务将会被拒绝,具体方式按照拒绝策略执行

拒绝策略

当前同时运行的线程数量达到最大线程数量且等待队列中放满了任务时,新进入的任务将会按照拒绝策略执行,ThreadPoolTaskExecutor定义一些策略:

  • ThreadPoolExecutor.AbortPolicy 抛出 RejectedExecutionException来拒绝新任务的处理。
  • ThreadPoolExecutor.CallerRunsPolicy 调用执行自己的线程运行任务,也就是直接在调用execute方法的线程中运行(run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。
  • ThreadPoolExecutor.DiscardPolicy 不处理新任务,直接丢弃掉。
  • ThreadPoolExecutor.DiscardOldestPolicy 此策略将丢弃最早的未处理的任务请求。

自定义拒绝策略:

实现RejectedExecutionHandler接口,重写**rejectedExecution**方法。

阻塞队列

不同的线程池会选用不同的阻塞队列,我们可以结合内置线程池来分析。

  • 容量为 Integer.MAX_VALUELinkedBlockingQueue(无界队列):FixedThreadPoolSingleThreadExector 。由于队列永远不会被放满,因此FixedThreadPool最多只能创建核心线程数的线程。
  • SynchronousQueue(同步队列) :CachedThreadPoolSynchronousQueue 没有容量,不存储元素,目的是保证对于提交的任务,如果有空闲线程,则使用空闲线程来处理;否则新建一个线程来处理任务。也就是说,CachedThreadPool 的最大线程数是 Integer.MAX_VALUE ,可以理解为线程数是可以无限扩展的,可能会创建大量线程,从而导致 OOM。
  • DelayedWorkQueue(延迟阻塞队列):ScheduledThreadPoolSingleThreadScheduledExecutorDelayedWorkQueue 的内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构,可以保证每次出队的任务都是当前队列中执行时间最靠前的。DelayedWorkQueue 添加元素满了之后会自动扩容原来容量的 1/2,即永远不会阻塞,最大扩容可达 Integer.MAX_VALUE,所以最多只能创建核心线程数的线程。

大小设置

线程池大小设置要根据任务类型来判断,任务分为CPU密集型任务IO密集型任务。CPU密集性任务主要消耗的是CPU的计算资源,IO密集型任务主要是等待IO执行,消耗CPU资源较少。

最佳线程数 = N(CPU 核心数)∗(1+WT(线程等待时间)/ST(线程计算时间)),其中 WT(线程等待时间)=线程运行总时间 - ST(线程计算时间)

其他

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值