继续浏览精彩内容
慕课网APP
程序员的梦工厂
打开
继续
感谢您的支持,我会继续努力的
赞赏金额会直接到老师账户
将二维码发送给自己后长按识别
微信支付
支付宝支付

阿里面试P6以上必问:并发编程

米脂
关注TA
已关注
手记 492
粉丝 88
获赞 590

Java并发编程在实际的工作中应用广泛,有时候需要通过多线程去异步做一些事情,有时候需要通过多线程提升一个任务执行的效率。互联网公司面试最常问到的点。本文有点长,代码比较多,请耐心看完,提升是需要一个学习的过程。

关键概念

上下文切换

  1. 概念:CPU通过时间片算法,给可运行的线程分配运行时间,在不同线程之间的切换时需要将当前线程的状态保存并回复将要执行的线程状态信息,这个过程就是上下文切换。

  2. 如何减少或避免上下文切换?

  • 无锁并发编程

  • CAS算法

  • 使用最少线程

  • 协程

死锁

  1. 概念:两个或多个线程持有对方正在等待的锁

  2. 如何避免死锁?

  • 避免一个线程同时获取多个锁

  • 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源

  • 尝试使用定时锁

  • 对于数据库锁,加锁和解锁必须在一个数据库连接里

Java并发的底层机制

volatile

  1. 作用:在多处理器开发中保证多线程之间的共享变量的可见性,即一个线程修改该变量的值时,其他的线程可以立即看到该变量最新的值。

  2. 原理:对被volatile修饰的变量进行写操作时,会做如下两个事情

  • 将当前处理器缓存行的数据写到系统内存;

  • 使得其他CPU里缓存了该内存地址的数据无效

  1. 使用要点:

  • volatile只能保证可见性,无法保证同步性。举个例子:如果针对某个变量的改变后的值依赖于上次改变的值,使用volatile就无法保证并发安全了

synchronized

  1. 定义:synchronized是Java多线程之间的一种通信方式。synchronized的具体应用有三种:

  • 对于普通同步方法,锁是当前实例对象

  • 对于静态同步方法,锁是当前类的Class对象

  • 对于同步代码块,锁是synchronized括号里配置的对象

  1. 使用要点:

  • 构造方法不能用synchronized修饰

  • 推荐尽量减小锁的粒度,例如,使用同步代码块可以满足需求就不需要使用同步方法

  • 如果可以确认应用中的所有锁在大多数情况下都由不同的线程竞争,可以通过-XX:+UseBiasedLocking禁用偏向锁,提升性能。

  1. 原理:介绍两个概念,Monitor Record(Thread类的私有数据结构)和Java对象头,关系是:Java对象头中存储了Monitor Record的地址,Monitor Record中记录了持有它的线程。

  • monitor:monitor不是一个特殊的对象,是一种方法或机制,Java通过monitor来控制对某个对象的访问。Java中的每个对象都和一个monitor相关联。在同一个时刻,只有一个线程(Thread)可以锁定一个monitor。当某个monitor被一个线程锁定时,其他试图锁定这个monitor的线程只能block等待。

  • 对象头:synchronized的锁状态描述在Java对象的头部。对象头中包括Mark word和Klass Word。

  1. 在32位虚拟机中,整个对象头大小是64bits(即8字节),Mark Word和Klass Word分别占用4字节。

  2. 锁状态:Java中的锁按照级别从低到高有四种,无锁状态——>偏向锁——>轻量级锁——>重量级锁。偏向锁是依赖Mark Word中的一个指向当前线程的字段来标识该锁的持有者是否是当前线程,如果是则直接进入同步代码块;假设禁用了偏向锁,轻量级锁指的是两个线程获取锁,一个获取到,另一个获取不成功的状态,首先会CAS自旋获取锁,如果CAS自旋获取失败,该轻量级锁就会膨胀为重量级锁,当前获取锁失败的线程进入阻塞状态。

  3. 锁升级的过程,只会从低到高,不会从高到低,避免不必要的资源浪费。举个例子,如果一个锁的状态已经达到重量级锁,后面再来竞争这个锁的线程都会直接进入阻塞,不会再进行CAS自旋。参考资料7中提供的一张图很精致,我放在这里:

阿里面试P4以上必问:并发编程

Object Header(32位虚拟机)

阿里面试P4以上必问:并发编程

原子操作

CPU级别的原子操作

在CPU级别实现原子操作需要依靠CPU指令完成,CPU指令通过总线操作内存中的数据,因此在CPU中有两个方式:

  1. 锁总线:利用LOCK指令向总线发出信号,实现一个

  2. 锁缓存:在某个一时刻,只需要保证对某个内存地址的操作是原子性的;

JAVA中的原子操作

在Java中可以通过CAS和锁来实现原子操作。

  1. 使用CAS实现原子操作,从Java1.5开始,java.lang.concurrent包里提供了很多类来支持原子操作,例如AotmicIntenger、AtomicLong,这些类可以以原子的方式将变量的当前值加1或减1;

  2. 使用锁实现原子操作,锁机制确保只有持有锁的线程才能操作指定的变量;

都说到这里了,面试必不可少问的---并发编程之线程池的使用及扩展和优化

现在可以说是代码时间了

简而言之,在使用线程池后,创建线程便处理从线程池获得空闲线程,关闭线程变成了向池子归还线程。也就是说,提高了线程的复用。

而 JDK 在 1.5 之后为我提供了现成的线程池工具,我们今天就来学习看看如何使用他们。

  1. Executors 线程池工厂能创建哪些线程池

  2. 如何手动创建线程池

  3. 如何扩展线程池

  4. 如何优化线程池的异常信息

  5. 如何设计线程池中的线程数量

1. Executors 线程池工厂能创建哪些线程池

先来一个最简单的线程池使用例子:

staticclass MyTask implements Runnable {

@Override

public void run() {

System.out

.println(System.currentTimeMillis() + ": Thread ID :" + Thread.currentThread().getId());

try {

Thread.sleep(1000);

} catch (InterruptedException e) {

e.printStackTrace();

}

}

}

public static void main(String[] args) {

MyTask myTask = new MyTask();

ExecutorService service1 = Executors.newFixedThreadPool(5);

for (int i = 0; i < 10; i++) {

service1.submit(myTask);

}

service1.shutdown();

}

运行结果:

阿里面试P4以上必问:并发编程

我们创建了一个线程池实例,并设置默认线程数量为5,并向线程池提交了10任务,分别打印当前毫秒时间和线程ID,从结果中,我们可以看到结果中有5个相同 id 的线程打印了毫秒时间。

这是最简单的例子。

接下来我们讲讲其他的线程创建方式。

1. 固定线程池ExecutorService service1 = Executors.newFixedThreadPool(5);该方法返回一个固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行,若没有,则新的任务会被暂存在一个任务队列(默认无界队列 int 最大数)中,待有线程空闲时,便处理在任务队列中的任务。

2. 单例线程池ExecutorService service3 = Executors.newSingleThreadExecutor();该方法返回一个只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列(默认无界队列 int 最大数)中,待线程空闲,按先入先出的顺序执行队列中的任务。

3. 缓存线程池ExecutorService service2 = Executors.newCachedThreadPool();该方法返回一个可根据实际情况调整线程数量的线程池,线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程,所有线程均在工作,如果有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。

4. 任务调用线程池ExecutorService service4 = Executors.newScheduledThreadPool(2);该方法也返回一个 ScheduledThreadPoolExecutor 对象,该线程池可以指定线程数量。

前3个线程的用法没什么差异,关键是第四个,虽然线程任务调度框架很多,但是我们仍然可以学习该线程池。如何使用呢?下面来个例子:

class A {

public static void main(String[] args) {

ScheduledThreadPoolExecutor service4 = (ScheduledThreadPoolExecutor) Executors

.newScheduledThreadPool(2);

// 如果前面的任务没有完成,则调度也不会启动

service4.scheduleAtFixedRate(new Runnable() {

@Override

public void run() {

try {

// 如果任务执行时间大于间隔时间,那么就以执行时间为准(防止任务出现堆叠)。

Thread.sleep(10000);

System.out.println(System.currentTimeMillis() / 1000);

} catch (InterruptedException e) {

e.printStackTrace();

}

}// initialDelay(初始延迟) 表示第一次延时时间 ; period 表示间隔时间

}, 0, 2, TimeUnit.SECONDS);

service4.scheduleWithFixedDelay(new Runnable() {

@Override

public void run() {

try {

Thread.sleep(5000);

System.out.println(System.currentTimeMillis() / 1000);

} catch (InterruptedException e) {

e.printStackTrace();

}

}// initialDelay(初始延迟) 表示延时时间;delay + 任务执行时间 = 等于间隔时间 period

}, 0, 2, TimeUnit.SECONDS);

// 在给定时间,对任务进行一次调度

service4.schedule(new Runnable() {

@Override

public void run() {

System.out.println("5 秒之后执行 schedule");

}

}, 5, TimeUnit.SECONDS);

}

}

}

上面的代码创建了一个 ScheduledThreadPoolExecutor 任务调度线程池,分别调用了3个方法,需要着重解释 scheduleAtFixedRate 和 scheduleWithFixedDelay 方法,这两个方法的作用很相似,唯一的区别就是他们执行人物的间隔时间的计算方式,前者时间间隔算法是根据指定的 period 时间和任务执行时间中取时间长的,后者取的是指定的 delay 时间 + 任务执行时间。如果同学们有兴趣,可以将上面的代码跑跑看。一样便能看出端倪。

好了,JDK 给我们封装了创建线程池的 4 个方法,但是,请注意,由于这些方法高度封装,因此,如果使用不当,出了问题将无从排查,因此,我建议,程序员应到自己手动创建线程池,而手动创建的前提就是高度了解线程池的参数设置。那么我们就来看看如何手动创建线程池。

2. 如何手动创建线程池

下面是一个手动创建线程池的范本:

/**

* 默认5条线程(默认数量,即最少数量),

* 最大20线程(指定了线程池中的最大线程数量),

* 空闲时间0秒(当线程池梳理超过核心数量时,多余的空闲时间的存活时间,即超过核心线程数量的空闲线程,在多长时间内,会被销毁),

* 等待队列长度1024,

* 线程名称[MXR-Task-%d],方便回溯,

* 拒绝策略:当任务队列已满,抛出RejectedExecutionException

* 异常。

*/

private static ThreadPoolExecutor threadPool = new ThreadPoolExecutor(5, 20, 0L,

TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(1024)

, new ThreadFactoryBuilder().setNameFormat("My-Task-%d").build()

, new AbortPolicy()

);

我们看到,ThreadPoolExecutor 也就是线程池有 7 个参数,我们一起来好好看看:

  1. corePoolSize 线程池中核心线程数量

  2. maximumPoolSize 最大线程数量

  3. keepAliveTime 空闲时间(当线程池梳理超过核心数量时,多余的空闲时间的存活时间,即超过核心线程数量的空闲线程,在多长时间内,会被销毁)

  4. unit 时间单位

  5. workQueue 当核心线程工作已满,需要存储任务的队列

  6. threadFactory 创建线程的工厂

  7. handler 当队列满了之后的拒绝策略

前面几个参数我们就不讲了,很简单,主要是后面几个参数,队列,线程工厂,拒绝策略。

我们先看看队列,线程池默认提供了 4 个队列。

  1. 无界队列: 默认大小 int 最大值,因此可能会耗尽系统内存,引起OOM,非常危险。

  2. 直接提交的队列 : 没有容量,不会保存,直接创建新的线程,因此需要设置很大的线程池数。否则容易执行拒绝策略,也很危险。

  3. 有界队列:如果core满了,则存储在队列中,如果core满了且队列满了,则创建线程,直到maximumPoolSize 到了,如果队列满了且最大线程数已经到了,则执行拒绝策略。

  4. 优先级队列:按照优先级执行任务。也可以设置大小。

楼主在自己的项目中使用了无界队列,但是设置了任务大小,1024。如果你的任务很多,建议分为多个线程池。不要把鸡蛋放在一个篮子里。

再看看拒绝策略,什么是拒绝策略呢?当队列满了,如何处理那些仍然提交的任务。JDK 默认有4种策略。

  1. AbortPolicy :直接抛出异常,阻止系统正常工作.

  2. CallerRunsPolicy : 只要线程池未关闭,该策略直接在调用者线程中,运行当前被丢弃的任务。显然这样做不会真的丢弃任务,但是,任务提交线程的性能极有可能会急剧下降。

  3. DiscardOldestPolicy: 该策略将丢弃最老的一个请求,也就是即将被执行的一个任务,并尝试再次提交当前任务.

  4. DiscardPolicy: 该策略默默地丢弃无法处理的任务,不予任何处理,如果允许任务丢失,我觉得这是最好的方案.

当然,如果你不满意JDK提供的拒绝策略,可以自己实现,只需要实现 RejectedExecutionHandler 接口,并重写 rejectedExecution 方法即可。

最后,线程工厂,线程池的所有线程都由线程工厂来创建,而默认的线程工厂太过单一,我们看看默认的线程工厂是如何创建线程的:

/**

* The default thread factory

*/

static class DefaultThreadFactory implements ThreadFactory {

private static final AtomicInteger poolNumber = new AtomicInteger(1);

private final ThreadGroup group;

private final AtomicInteger threadNumber = new AtomicInteger(1);

private final String namePrefix;

DefaultThreadFactory() {

SecurityManager s = System.getSecurityManager();

group = (s != null) ? s.getThreadGroup() :

Thread.currentThread().getThreadGroup();

namePrefix = "pool-" +

poolNumber.getAndIncrement() +

"-thread-";

}

public Thread newThread(Runnable r) {

Thread t = new Thread(group, r,

namePrefix + threadNumber.getAndIncrement(),

0);

if (t.isDaemon())

t.setDaemon(false);

if (t.getPriority() != Thread.NORM_PRIORITY)

t.setPriority(Thread.NORM_PRIORITY);

return t;

}

}

可以看到,线程名称为 pool- + 线程池编号 + -thread- + 线程编号 。设置为非守护线程。优先级为默认。

如果我们想修改名称呢?对,实现 ThreadFactory 接口,重写 newThread 方法即可。但是已经有人造好轮子了, 比如我们的例子中使用的 google 的 guaua 提供的 ThreadFactoryBuilder 工厂。可以自定义线程名称,是否守护,优先级,异常处理等等,功能强大。

3. 如何扩展线程池

那么我们能扩展线程池的功能吗?比如记录线程任务的执行时间。实际上,JDK 的线程池已经为我们预留的接口,在线程池核心方法中,有2 个方法是空的,就是给我们预留的。还有一个线程池退出时会调用的方法。我们看看例子:

/**

* 如何扩展线程池,重写 beforeExecute, afterExecute, terminated 方法,这三个方法默认是空的。

*

* 可以监控每个线程任务执行的开始和结束时间,或者自定义一些增强。

*

* 在 Worker 的 runWork 方法中,会调用这些方法

*/

public class ExtendThreadPoolDemo {

static class MyTask implements Runnable {

String name;

public MyTask(String name) {

this.name = name;

}

@Override

public void run() {

System.out

.println("正在执行:Thread ID:" + Thread.currentThread().getId() + ", Task Name = " + name);

try {

Thread.sleep(100);

} catch (InterruptedException e) {

e.printStackTrace();

}

}

}

public static void main(String[] args) throws InterruptedException {

ExecutorService es = new ThreadPoolExecutor(5, 5, 0L, TimeUnit.MILLISECONDS,

new LinkedBlockingQueue<>()) {

@Override

protected void beforeExecute(Thread t, Runnable r) {

System.out.println("准备执行:" + ((MyTask) r).name);

}

@Override

protected void afterExecute(Runnable r, Throwable t) {

System.out.println("执行完成: " + ((MyTask) r).name);

}

@Override

protected void terminated() {

System.out.println("线程池退出");

}

};

for (int i = 0; i < 5; i++) {

MyTask myTask = new MyTask("TASK-GEYM-" + i);

es.execute(myTask);

Thread.sleep(10);

}

es.shutdown();

}

}

我们重写了 beforeExecute 方法,也就是执行任务之前会调用该方法,而 afterExecute 方法则是在任务执行完毕后会调用该方法。还有一个 terminated 方法,在线程池退出时会调用该方法。执行结果是什么呢?

阿里面试P4以上必问:并发编程

可以看到,每个任务执行前后都会调用 before 和 after 方法。相当于执行了一个切面。而在调用 shutdown 方法后则会调用 terminated 方法。

4. 如何优化线程池的异常信息

如何优化线程池的异常信息? 在说这个问题之前,我们先说一个不容易发现的bug:

看代码:

public static void main(String[] args) throws ExecutionException, InterruptedException {

ThreadPoolExecutor executor = new ThreadPoolExecutor(0, Integer.MAX_VALUE, 0L,

TimeUnit.MILLISECONDS, new SynchronousQueue<>());

for (int i = 0; i < 5; i++) {

executor.submit(new DivTask(100, i));

}

}

static class DivTask implements Runnable {

int a, b;

public DivTask(int a, int b) {

this.a = a;

this.b = b;

}

@Override

public void run() {

double re = a / b;

System.out.println(re);

}

}

执行结果:

阿里面试P4以上必问:并发编程

注意:只有4个结果,其中一个结果被吞没了,并且没有任何信息。为什么呢?如果仔细看代码,会发现,在进行 100 / 0 的时候肯定会报错的,但是却没有报错信息,令人头痛,为什么呢?实际上,如果你使用 execute 方法则会打印错误信息,当你使用 submit 方法却没有调用它的get 方法,异常将会被吞没,因为,如果发生了异常,异常是作为返回值返回的。

怎么办呢?我们当然可以使用 execute 方法,但是我们可以有另一种方式:重写 submit 方法,楼主写了一个例子,大家看一下:

static class TraceThreadPoolExecutor extends ThreadPoolExecutor {

public TraceThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime,

TimeUnit unit, BlockingQueue<Runnable> workQueue) {

super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue);

}

@Override

public void execute(Runnable command) {

// super.execute(command);

super.execute(wrap(command, clientTrace(), Thread.currentThread().getName()));

}

@Override

public Future<?> submit(Runnable task) {

// return super.submit(task);

return super.submit(wrap(task, clientTrace(), Thread.currentThread().getName()));

}

private Exception clientTrace() {

return new Exception("Client stack trace");

}

private Runnable wrap(final Runnable task, final Exception clientStack,

String clientThreaName) {

return new Runnable() {

@Override

public void run() {

try {

task.run();

} catch (Exception e) {

e.printStackTrace();

clientStack.printStackTrace();

throw e;

}

}

};

}

}

我们重写了 submit 方法,封装了异常信息,如果发生了异常,将会打印堆栈信息。我们看看使用重写后的线程池后的结果是什么?

阿里面试P4以上必问:并发编程

从结果中,我们清楚的看到了错误信息的原因:by zero!并且堆栈信息明确,方便排错。优化了默认线程池的策略。

5. 如何设计线程池中的线程数量

线程池的大小对系统的性能有一定的影响,过大或者过小的线程数量都无法发挥最优的系统性能,但是线程池大小的确定也不需要做的非常精确。因为只要避免极大和极小两种情况,线程池的大小对性能的影响都不会影响太大,一般来说,确定线程池的大小需要考虑CPU数量,内存大小等因素,在《Java Concurrency in Practice》 书中给出了一个估算线程池大小的经验公式:

阿里面试P4以上必问:并发编程

公式还是有点复杂的,简单来说,就是如果你是CPU密集型运算,那么线程数量和CPU核心数相同就好,避免了大量无用的切换线程上下文,如果你是IO密集型的话,需要大量等待,那么线程数可以设置的多一些,比如CPU核心乘以2.

至于如何获取 CPU 核心数,Java 提供了一个方法:

Runtime.getRuntime().availableProcessors();

返回了CPU的核心数量。

总结

好了,到这里,我们已经对高并发,如何使用线程池有了一个认识,这里,楼主建议大家手动创建线程池,这样对线程池中的各个参数可以有精准的了解,在对系统进行排错或者调优的时候有好处。比如设置核心线程数多少合适,最大线程数,拒绝策略,线程工厂,队列的大小和类型等等,也可以是G家的线程工厂自定义线程。

最后分享一个学习路线,也可以加群:433540541获取。

阿里面试P4以上必问:并发编程


原文:https://342104628.iteye.com/blog/2422044

打开App,阅读手记
0人推荐
发表评论
随时随地看视频慕课网APP