一问一答之并发编程
前言
详细见juc笔记
为什么需要并发编程
提高CPU的利用率,提供系统整体的并发能力和性能
缺点
不能提高程序运行速度,线程安全,内存泄漏问题
并发和并行的区别
- 并发有同时处理任务的能力,不一定是同时(cpu时间片交替执行,逻辑上看着是同时,实则不是,使用的一个cpu核心)
- 并行是可以同时处理任务(真正的多核运行)
谈谈进程和线程的区别
进程是资源分配的最小单位,每个进程都有自己的资源和内存空间,但切换时会有比较大的开销。为了提高切换效率,减少调度时间,就出现了线程,它是程序执行的最小单位,一个进程通常包含多个线程。并且线程共享本进程的空间
和资源
谈谈CAS
compare and swap 比较并交换,是一个原子性操作
如何操作?有三个操作数,当前值A,内存值V,要修改的值B。假设A == V,将V改为B。如果A != V要么重试,要么放弃
为什么要用CAS?
synchronized每次仅允许一个线程去操作共享资源,CAS相当于没有加锁,多个线程都可以操作资源,在实际修改的时候再去判断,所以要比synchronized高效
CAS的缺点?带来ABA的问题,假设线程A读到的值为10,B为修改为了20,C又修改为了10,在A来说,与内存中的值是一致的,认为没有变过,但实际是变过的。这就是ABA问题。通常使用内存值 + 版本号的方式去解决(AtomicStampedReference)
谈谈synchronized
互斥锁,每次只能允许一个线程访问共享资源
修饰的是方法 / 代码块,锁的是对象实例
修饰的静态方法,锁的类实例
原理
:通过反编译class字节码文件发现会生成ACC_SYNCRONIZED关键字来标识。修饰代码块会依赖monitorenter和monitorexit指令
对象由三部分组成:对象头
、实际数据
、对齐填充
。
重点在于对象头,分为几部分,重点是mark word信息会记录锁的信息。
在1.6之前是重量级锁,来了就阻塞别的线程。加锁是底层操作系统的行为会涉及到内核态和用户态的切换,非常消耗性能
之后进行了锁升级。引入了偏向锁
和轻量级锁
,在JVM层面加锁,不依赖操作系统,没有切换的消耗
偏向锁:没有竞争环境。markword会记录线程id,线程执行代码前需要比较id,相同则执行代码,如果不同则执行CAS修改,如果修改成功,执行代码,失败说明有竞争,撤销偏向锁,升级为轻量级锁
轻量级锁:当前线程会在栈帧下创建Lock Record,会把mark record信息拷贝进去,且owner指针指向加锁对象。线程执行代码时,用CAS将mark word指向到lock record,如果成功获取到锁,失败则自旋重试,重试到一定次数后升级为重量级锁
谈谈AQS
全称叫做AbstractQueuedSyncronizer,实现锁的一个框架,内部实现关键在于维护了一个先进先出的队列
与state状态变量
state则表示当前锁的状态,像ReentrantLock、CountDownLatch、Semaphore都是基于AQS实现的
AQS支持独占和共享两种模式
加锁和解锁过程如图
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,如果优先级相同就随机选择一个线程
发生以下情况,会终止线程的运行
- 调用yield方法让出cpu的占用权力
- 调用sleep方法让线程睡眠
- IO阻塞
- 一个优先级更高的线程出现
- 时间片用完
如何避免线程死锁?
破坏死锁产生的四个条件就可以
破坏互斥条件
使用锁本来就是互斥的,没办法破坏这个
破坏请求和保持条件
一次性申请所有的资源,并发能力会下降,一般不破坏这个条件
破坏不剥夺条件
如果其他资源申请不到,可以主动释放这个资源
破坏循环等待条件
按照某一顺序申请资源,释放资源则反序释放
sleep()和wait()有什么区别?
- sleep是Thread的类方法,wait是Object的方法
- sleep不会释放锁,wait会释放锁
- sleep通常被用于暂停线程执行,wait用于线程间的交互与通信
- sleep线程会自动唤醒,wait需要别的线程调用notify()或者notifyAll()唤醒
synchronized升级原理
升级过程
谈谈volatile
- 保证可见性,防止主存与缓存数据的不一致。指示jvm,到主存中读取数据
- 防止JVM指令重排,保证多线程环境下也能正常运行
- 不支持原子性
- 写的时候直接刷新到主内存中,读的时候直接从主内存中读取,复制到工作内存
为什么能保证可见和重排序内存屏障
。其实就是JVM的一种指令,JMM的重排规则会要求Java编译器在生成JVM指令时插入特定的内存屏障指令,通过这些指令,实现了可见性和有序性
写屏障:告诉CPU在看到store指令,就必须把指令之前的所有数据都同步到主内存中
读屏障:在看到Load指令后,让工作内存和cpu高速缓存中的数据失效,重新回到主存中获取数据
细分四种屏障类型(了解)
为什么不保证原子性
volatile的可见性保证线程每次在读的时候数据是最新的。但多线程环境下,计算赋值的操作屡次出现,若在计算期间,被volatile修饰的值修改完毕(其他线程),则当前线程的计算作废,所以操作可能会出现丢失问题,进而导致数据不一致
应用场景
- 单一赋值,不包含i++之类的复合赋值
- 读多于写,结合内部锁减少内存开销
- 单例模式 — 双重检查(先判空再加锁在判空)
谈谈四大引用
- 强引用:new出来的对象。宁愿抛OOM,也不会被回收
- 软引用:被softreference修饰,在内存将要溢出的时候进行回收
- 弱引用:被weekreference修饰,只要发生垃圾回收,那么就会被回收
- 虚引用:被phantomreference修饰,用队列接收对象即将死亡的通知
谈谈你对threadLocal的理解
为什么要用?
在多线程编程中通常解决线程安全问题会利用synchronized或lock,但这种方式会让未获取到锁的线程阻塞等待,那么如果每个线程都拥有自己的资源,互不影响,这样也不会出现安全问题,这就是threadLocal出现的原因,是一种空间换时间的做法
是什么
顾名思义,表示线程的“本地变量”,即每个线程都拥有该变量副本,与线程之间互不影响
场景
spring,threadLocal的存储类型是一个map,key是datasource,value是connection(为了应对多数据源),用
ThreadLocal会内存泄露吗?
详细文章:https://www.jianshu.com/p/dde92ec37bd1
会。ThreadLocal中包含了ThreadLocalMap,然而ThreadLocalMap的对象是在Thread中的,如果Thread没有结束,则ThreadLocalMap一直不会释放,假如ThreadLocalMap中设置了很多值,而且没有手动设置remove(),则可能会造成内存泄露。
为什么将map中的key设置为弱引用
假设如果存储的强引用,其目的是通过threadLocal==null来对threadLocal进行回收。但事实恰好相反
在业务代码中执行threadLocal==null操作,由于threadLocalMap的Entry强引用threadLocal,因此在gc的时候进行可达性分析,threadLocal依然可达,对threadLocal并不会进行垃圾回收,这样就无法通过threadLocal==null来对threadLocal进行回收,出现逻辑错误
如果为弱引用,在threadLocal生命周期里会尽可能的保证不出现内存泄漏的问题,达到安全的状态。
在threadLocal的生命周期里(set,getEntry,remove)里,都会针对key为null的脏entry进行处理。
但还可能出现内存泄漏,所以还需要手动remove
原子操作类
atomic operation是在多线程环境下避免数据不一致的手段,i++不是一个原子操作,在读取+1时,其他线程可能读取之前的值
jdk5之前采取同步的方式来保证,之后使用java.util.concurrent.atomic包提供了int和long类型的原子包装类,它们可以自动的保证对于他们的操作是原子的并且不需要使用同步
原子类:AtomicBoolean,AtomicInteger,AtomicLong,AtomicReference
原子数组:AtomicIntegerArray,AtomicLongArray,AtomicReferenceArray
原子属性更新器:AtomicLongFieldUpdater,AtomicIntegerFieldUpdater,AtomicReferenceFieldUpdater
如何保证?
多个线程只能保证一个修改成功,具有排他性,其他未成功的线程则自选等待,直到执行成功
引用一段AtomicInteger的源码
1 | // setup to use Unsafe.compareAndSwapInt for updates |
使用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 操作完成上
扩展
更多内容可点击参照这篇文章
函数式接口
为什么会有公平锁与非公平锁的设计?为什么默认非公平?使用场景?
非公平锁可以更加充分的利用cpu的时间片
当一个线程请求锁获取同步状态,然后释放同步状态,刚释放的锁线程在此刻获取同步的概率就会变得非常大(刚释放的正在使用cpu时间片),减少了性能的开销
看业务场景,追求性能非公平,雨露均沾选公平
产生死锁的原因有什么?如何排查
1.系统资源不足
2.进程顺序不合适
3.资源分配不当
使用命令jps -l找到进程号,用jstack 进程号,查看诊断信息
如何终止运行中的线程
线程无法立即被停止,也不应该由其他线程去终止。只是一种协商机制
thread提供了一种interrupt方法,他只会将对象中的中断标识设置为true,写代码根据这个标识去判断即可
正解:1.通过volatile变量实现。2.通过AtomicBoolean。3.通过thread的api实现
当一个线程正在调用中断方法时,必须保证线程是一个正常运行的状态。如果线程处于阻塞状态(sleep,wait,join等) 那么线程会立即停止阻塞状态,并抛出interruptedException异常
静态interrupt方法与对象方法的区别
调的是同一个方法,不同的是传入的clear参数不同,静态的传的true,会将线程的中断状态清空
唤醒线程的方法有哪些
wait,notify — 必须先持有锁(synchronized),否则抛异常,必须先调用wait在调用notify,否则程序阻塞
juc中的await和singal — 同上情况,锁换为了(lock)
lockSupport的park和unpark。无锁块要求,先唤醒后等待支持,需要成双成对使用
谈谈你对volatile的理解
两大特性
有可见性和禁重排特性分别阐述
可见性,写和读操作都是针对于主内存中,保证数据是最新的。
写完会立即刷新到主内存中。
当读的时候会使该线程工作内存中的数据失效,重新去主内存中去读。
禁重排
有内存屏障的存在使得被修饰的变量语句顺序不会重排
JVM会在生成字节码时加入ACC_VOLATILE关键字,按照JMM的规范在相应的位置插入内存屏障
ThreadLocal
1.与ThreadLocalMap和Thread的关系
2.key是弱引用的原因
ThreadLocalmap继承了弱引用
3.内存泄露的问题
4.最后为什么加remove方法
不会影响线程池复用对之前线程造成的影响
谈谈读写锁ReentrantReadWriteLock
读读共享,读写互斥
为什么要用它,ReentrantLock不行?ReentrantLock多个线程读只有一个线程操作
而读写锁多个线程读是可以共享的
带来了缺点,写锁饥饿的问题,出现了锁降级(将为读锁,不能反之升级 )
写锁饥饿,如果读锁比较多,写锁抢不到
谈谈对线程池的理解
线程在每次创建和销毁时都是在内核进行的,这就导致可能创建和销毁的花费比任务花费的时间要多
线程池的出现是为了提高线程的复用性
降低资源消耗:重复利用一定的线程,频繁创建线程会消耗资源,还会降低系统稳定性
提高响应速度:当任务到达时,不需要等待创建线程的时间
项目中用到了么?
用到了,我负责的项目模块中有一个年度价值统计,需要创建统计模板定时发送信息。每个模板对应一个id,需要在hdfs中去找这个文件,遍历查找操作使用了线程池,因为hdfs遍历操作是一个费时任务,将此过程进行异步化,提高系统的吞吐量,使用ThreadPoolExcutor创建线程池,而不是使用executor(阿里手册不推荐,使用原生方法更能理解线程池运行规则,避免资源被耗尽的风险)
如何指定线程数?
看业务情况
cpu密集型:n+1
io密集型:2n
究竟开多少,可以通过压测定
具体的可以看juc笔记