前言

详细见juc笔记

为什么需要并发编程

提高CPU的利用率,提供系统整体的并发能力和性能

缺点

不能提高程序运行速度,线程安全,内存泄漏问题

并发和并行的区别

  • 并发有同时处理任务的能力,不一定是同时(cpu时间片交替执行,逻辑上看着是同时,实则不是,使用的一个cpu核心)
  • 并行是可以同时处理任务(真正的多核运行)

进程和线程的区别

  • 进程:资源分配的最小单位,指在内存中独立运行的一个程序
  • 线程:程序执行的最小单位,一个进程可以包含多个线程
  • 程序间的切换会有较大的开销,线程切换的开销小
  • 线程共享本进程的空间和资源,进程的空间和资源是相互独立的

synchronized和ReentrantLock的区别

  • lock是一个接口,synchronized是关键字,内置语言实现
  • synchronized发生异常会自动释放占有的锁,因此不会导致死锁,lock如果没有unlock释放锁,很可能死锁,所以最好放到finally中
  • lock可以响应中断,synchronized不行,会一直等待
  • lock可以知道有没有获取锁,synchronized不行
  • lock可以提高多个线程读操作的效率
  • 竞争资源激烈的情况下,推荐使用lock

什么是上下文切换

当前任务在执行完cpu时间片切换到另一个任务之前会保存自己之前的状态,以便下次再切回这个状态,从保存到再次被加载的过程称为一次上下文切换

为什么我们调用 start() 方法时会执行 run() 方法,为什么我们不能直接调用 run() 方法?

调用 start 方法方可启动线程并使线程进入就绪状态,而 run 方法只是 thread 的一个普通方法调用,还是在主线程里执行。

Callable和Future的关系?

Future接口表示一个异步任务,callable用于产生结果,future用于获取结果

什么是FutureTask?

FutureTask表示一个异步任务,可以传一个callable具体实现类,对这个异步运算结果进行等待获取,判断是否可以完成,取消任务等操作。只有在运算完成才可以将结果返回,如果没有完成会加一个get方法进行阻塞。Future对callable和runnable的对象进行了包装,Future也是runnable的实现类,所以也可以放入线程池中

说说Java中的线程调度算法

计算机的cpu在任意时刻只能执行一条机器指令,每个线程只有在获得cpu的使用权才可以执行指令。多线程的并发运行,实际上是各个线程轮流获取cpu的使用权,在运行池中会有多个就绪状态的线程等待cpu,jvm负责线程的调度,为多个线程分配cpu的使用权

两种调度模型:分时调度模型和抢占式调度模型

分时调度模型:让线程轮流获取cpu的使用权,平均分配每个线程占用的cpu时间片

抢占式调度模型:让优先级高的先占用cpu,如果优先级相同就随机选择一个线程

发生以下情况,会终止线程的运行

  1. 调用yield方法让出cpu的占用权力
  2. 调用sleep方法让线程睡眠
  3. IO阻塞
  4. 一个优先级更高的线程出现
  5. 时间片用完

如何避免线程死锁?

破坏死锁产生的四个条件就可以

破坏互斥条件

使用所本来就是互斥的,没办法破坏这个

破坏请求和保持条件

一次性申请所有的资源,并发能力会下降,一般不破坏这个条件

破坏不剥夺条件

如果其他资源申请不到,可以主动释放这个资源

破坏循环等待条件

按照某一顺序申请资源,释放资源则反序释放

sleep()和wait()有什么区别?

  • sleep是Thread的类方法,wait是Object的方法
  • sleep不会释放锁,wait会释放锁
  • sleep通常被用于暂停线程执行,wait用于线程间的交互与通信
  • sleep线程会自动唤醒,wait需要别的线程调用notify()或者notifyAll()唤醒

synchronized升级原理

sync

升级过程

syncup

谈谈volatile

  • 保证可见性,防止主存与缓存数据的不一致。指示jvm,到主存中读取数据
  • 防止JVM指令重排,保证多线程环境下也能正常运行

谈谈四大引用

  • 强引用:new出来的对象。宁愿抛OOM,也不会被回收
  • 软引用:被softreference修饰,在内存将要溢出的时候进行回收
  • 弱引用:被weekreference修饰,只要发生垃圾回收,那么就会被回收
  • 虚引用:被phantomreference修饰,用队列接收对象即将死亡的通知

你用过ThreadLocal吗?

spring,threadLocal的存储类型是一个map,key是datasource,value是connection(为了应对多数据源),用threadLocal保证统一线程获取的是一个connection对象,从而保证一次事务所有操作需要在统一数据库连接上(一个实现场景)

ThreadLocal会内存泄露吗?

ThreadLocal是一个外壳,真正的存储结构是其中的Map,但这个map是在Thread上定义的,ThreadLocal本身不提供值,作为key让线程从map中获取value。内存泄漏指ThreadLocal回收了,map entry的key没有了指向,但引用一直存在,导致了内存泄漏。解决的最佳方式为用完之后手动remove掉

原子操作类

atomic operation是在多线程环境下避免数据不一致的手段,int++不是一个原子操作,在读取+1时,其他线程可能读取之前的值

jdk5之前采取同步的方式来保证,之后使用java.util.concurrent.atomic包提供了 int 和long 类型的原子包装类,它们可以自动的保证对于他们的操作是原子的并且不需要使用同步

原子类:AtomicBoolean,AtomicInteger,AtomicLong,AtomicReference

原子数组:AtomicIntegerArray,AtomicLongArray,AtomicReferenceArray

原子属性更新器:AtomicLongFieldUpdater,AtomicIntegerFieldUpdater,AtomicReferenceFieldUpdater

如何保证?

多个线程只能保证一个修改成功,具有排他性,其他未成功的线程则自选等待,直到执行成功

引用一段AtomicInteger的源码

1
2
3
4
5
6
7
8
9
10
11
12
// setup to use Unsafe.compareAndSwapInt for updates
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;

static {
try {
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}

private volatile int value;

使用CAS、volatile和 native方法保证原子性,从而避免syncronized的性能问题,执行效率大大提高

CAS的原理是拿期望的值和原本的一个值作比较,如果相同则更新成新的值。UnSafe 类的 objectFieldOffset() 方法是一个本地方法,这个方法是用来拿到“原来的值”的内存地址,返回值是 valueOffset。另外 value 是一个volatile变量,在内存中可见,因此 JVM 可以保证任何时刻任何线程总能拿到该变量的最新值。

常用的并发工具类有哪些?

  • Semaphore(信号量,收集龙珠)-允许多个线程同时访问某个资源:synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,Semaphore(信号量)可以指定多个线程同时访问某个资源。
  • CountDownLatch(倒计时器,计数器):CountDownLatch一般用于某个线程A等待若干个其他线程执行完任务之后,它才执行。强调一个线程等多个线程完成某件事情。CountDownLatch方法比较少,操作比较简单。CountDownLatch是不能复用的。
  • CyclicBarrier(循环栅栏,停车场):CyclicBarrier 和 CountDownLatch 非常类似,它也可以实现线程间的技术等待,但是它的功能比 CountDownLatch 更加复杂和强大。主要应用场景和 CountDownLatch 类似。CyclicBarrier一般用于一组线程互相等待至某个状态,然后这一组线程再同时执行。CyclicBarrier是多个线程互等,等大家都完成,再携手共进。CyclicBarrier是可以复用的。

CountDownLatch原理:任务分为N个子线程去执行,state也初始化为N(注意N要与线程个数一致)。这N个子线程是并行执行的,每个子线程执行完后countDown()一次,state会CAS(Compare and Swap)减1。等到所有子线程都执行完后(即state=0),会unpark()主调用线程,然后主调用线程就会从await()函数返回,继续后余动作。

在工作中如何确定线程池的大小?

线程过多会导致增加上下文切换成本

多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。
上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。
Linux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少

类比于实现世界中的人类通过合作做某件事情,我们可以肯定的一点是线程池大小设置过大或者过小都会有问题,合适的才是最好。

如果我们设置的线程池数量太小的话,如果同一时间有大量任务/请求需要处理,可能会导致大量的请求/任务在任务队列中排队等待执行,甚至会出现任务队列满了之后任务/请求无法处理的情况,或者大量任务堆积在任务队列导致 OOM。这样很明显是有问题的! CPU 根本没有得到充分利用。

但是,如果我们设置线程数量太大,大量线程可能会同时在争取 CPU 资源,这样会导致大量的上下文切换,从而增加线程的执行时间,影响了整体执行效率。

有一个简单并且适用面比较广的公式:

CPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1,比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
I/O 密集型任务(2N): 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。
如何判断是 CPU 密集任务还是 IO 密集任务?

CPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。单凡涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上

扩展

更多内容可点击参照这篇文章