多线程高并发演进梳理
逆向APP的核心目的之一就是写爬虫爬取后台的数据,诸如电商、评论、弹幕等;另一个目的就是提供sign字段的生成服务,可以通过https服务接口的形式给第三方调用!不论是做啥,为了提高效率,多线程都是必须的!可一旦涉及到多线程,线程之间的同步和互斥就必须考虑了,包括生产者和消费者之间、生产者和生产者之间、消费者和消费者之间的同步或互斥!为了解决这些问题,java逐步推出了syncronized、volatile、各种list/array、map、线程池ThreadPool等接口,内容巨多,是时候提炼、总结一下这些功能或接口的作用了!
1、先看看最简单的一种情况:多线程用的最多的就是这种生产、消费则模型了!凡是学过软件编程的都懂:生产者往共享内存(一般是某种特定的数据结构,比如链表、数组等)写数据,消费者从共享内存读数据!
(1)为了让生产者和消费者之间同步或互斥,不至于往共享内存读写错误的数据,java发明了两个“关键字”:syncronized和volatile!
- syncronized:锁定某段比较关键的代码,让这段代码同一时间只能有一个线程执行;最常见的就是对某个变量加减时加上syncronized,避免被其他读写线程干扰;
- 锁的本质:就是某个特定内存的数值,比如00表示无锁,01表示偏向锁,10表示自旋锁,11表示OS重锁等,java中用的是object instnace头的某个字段来记录;一旦某段内存被锁上,线程执行不下去、需要block的时候,会采取以下两个措施:
- 死循环:原地不断地轮询等待,直到锁被释放、自己得到锁为止,才继续执行代码,这种称之为自旋锁(就是原地打转的意思),也成为CAS!x86架构的cpu在硬件上提供了支持,底层采用cmpxchg或bts指令来实现;如果线程很多,大量线程都在自旋等待锁,会浪费大量的cpu时间片,甚至导致某些线程饿死,所以CAS自旋锁适合线程少的场景!
- 进入等待队列:此刻当前线程会让出cpu,进入等待队列(站在汇编代码层面看,就是jmp到其他代码执行了!),直到被其他线程唤醒、重新进入ready队列!这种方式可以避免浪费cpu时间片,但是切换线程会带来context保存的开销,应用场景和上面的CAS刚好相反!
- 锁的本质:就是某个特定内存的数值,比如00表示无锁,01表示偏向锁,10表示自旋锁,11表示OS重锁等,java中用的是object instnace头的某个字段来记录;一旦某段内存被锁上,线程执行不下去、需要block的时候,会采取以下两个措施:
- volatile:(1)强制让线程在内存读变量,而不是从cpu的cache读,保证同一个变量在多个线程/cpu core之间同步可见!(2)防止cpu指令重排序,打乱原本既定的执行顺序
(2) 多个线程同时工作,互相分工协作是必然的,光有锁代码、同步变量还不够啊,线程之间怎么互相联络通信了?好比一个团队有很多成员,成员之间肯定要沟通交流吧?还没有哪个大项目是一个人能搞定的,分工协作是必然,所以线程之间也涉及到通信,java又是怎么做的了?
- wait/notify/notifyAll: 线程如果遇到了无法继续执行代码的情况(比如共享内存满了,producer无法往里面写;或共享内存空了,consumer无法消费等),可以调用wait阻塞等待,不再继续执行代码!待条件时机成熟后,由其他线程调用notify或notifyAll唤醒继续执行!
- 作用类似的(本质就是线程间通信)的功能还有:
- ReentrantLock: lock.wait(),lock.signalAll()
- cyclicBarrier: barrier.await(); new CyclicBarrier(20, () -> System.out.println("满人"));
- ReadWriteLock: readWriteLock.readLock(); readWriteLock.writeLock();
- LockSupport: LockSupport.park();LockSupport.unpark()
- Semaphore: s.acquire(); s.release()
- CountDownLatch: latch.await(); latch.countDown();
2、上述就是最简单、最基础的生产者和消费者模型!如果让开发人员自己用多线程“同时”读写list、array等数据结构,无疑很麻烦:因为开发人员自己要考虑同步和互斥,涉及到syncronized和volatile的使用;为了方便开发在多线程安全的情况下轻松加愉快地读写常见的数据结构,java又推出了很多在多线程情况下安全的数据结构(其实就是在读写数据的关键代码前面加上syncronized和voltile,把所有应用开发人员都要做的重复性质的工作都抽象出来全做了):vector、hashTable、各种queue(Dqueue、blockingQueue、syncronizedQueue、transferQueue)、disruptor等!各种层出不穷数据结构的本质区别如下:
- 遇到代码执行不下去的时候怎么办?比如producer往满队列写数据,或者consumer从空队列读数据,这些场景很明显是有问题的,遇到了该怎么办了?无非也就两种:
- 阻塞等待呗:可以是自旋空转等待;也可以进入wait队列等待,然后被其他线程唤醒;所有的blockingQueue就是这么干的(不然怎么叫blocking了?)!
- 抛异常提示: 直接抛异常,返回null后代码继续执行
- 队列的数据读出来后要不要删除了?这就是poll和peek的区别!
- producer线程往队列写数据后,是直接返回,还是阻塞者等cunsumer拿走后才返回了?transferQueue.put方法就是阻塞,直到cunsumer消费后put方法才返回继续执行!
- 队列是线性的还是环形的ring buffer?disruptor采用的就是循环的ring buffer!从逻辑上把线性的队列抽象成了环形,只需要考虑数据的pos即可,pos=num%size!
经过java大牛们提供的各种线程安全数据结构后,开发人员终于不用考虑线程同步、互斥的情况了,可以直接读写队列的数据了,现在的生产、消费者模型如下了:
3、事已至此,多线程的开发就万事大吉了么?虽然中间的共享队列实现了多线程安全,还有线程本身的调度怎么解决了?举一些常见的例子:
- 线程是一直存在执行代码了,还是执行完代码就释放?如果频繁申请和释放线程,OS的开销非常大,怎么降低这方面的损耗?
- 线程之间怎么调度了?比如有些时候队列积压了大量数据要处理,是不是可以让某些produer停止工作,去充当cumsumer消费数据了?
- 线程什么时候开始执行代码了?执行途中要不要暂停了?执行完代码后又该干啥了?线程的生命周期怎么管理?
- 当代码执行不下去的时候,线程该怎么阻塞了?占着cpu原地自旋等待, 还是进入wait队列等待了?
- 代码如果要周期性地定时执行该怎么办了?
.....................
以上所有的工作如果都靠开发者手动实现,每个开发人员都要重复干这些事,整体的开发效率可想而知!java顺势而为,推出了大杀器: ThreadPool线程池!
(1)创建线程池也很简单,调用一个线程的API就够了,demo如下:
ThreadPoolExecutor tpe = new ThreadPoolExecutor(2, 4, //核心线程和最大线程 60, TimeUnit.SECONDS,//线程池的生存时间和单位 //不同的queue会产生不同的线程池;比如syncronizedQueue:来一个任务必须马上处理一个 new ArrayBlockingQueue<Runnable>(4),//任务队列,这里只能装4个任务 Executors.defaultThreadFactory(),//线程工厂:线程名 new ThreadPoolExecutor.CallerRunsPolicy());//main主线程自己执行其中一个任务
线程池有7个核心的参数,确定了线程池的大小、类型、执行任务的方式等,分别解释如下:
- corePoolSize: 线程池核心线程数。这个数量的线程会一直保持,不会释放;
- maximunPoolSize:最大线程数;当corePoolSize不够用的时候,线程池继续生成新线程,直到达到maximun的数量
- KeepAliveTime和TimeUnit:非核心线程长时间没活干就销毁或归还给os;核心线程一直保留,没有过期的说法
- workQueue:当task(本质就是一段需要被执行的代码)数量大于thread数量时,会导致task积压。这部分task就会进入queue,待thread读取出来后执行
- TreaFactory:线程工厂,可以自定义线程名称,便于后续出错抛异常时排查
- rejectExcutionHandler:当maximunPoolSize都无法全部执行task,并且workQueue也填满的时候,说明当前已经达到这个线程池处理的极限了!如果此时还有task加入,怎么拒绝了?直接抛异常丢弃?还是让添加task的线程自己处理(我已经满了,请不要再赛task给我了┭┮﹏┭┮)?
(2)所谓的task,本质就是一段需要被执行的代码,通常是开发人员根据业务需求而定制的代码。具体到coding层面,一般都是implement Runable接口,把业务逻辑代码放入run方法,比如下面这种:
static class Task implements Runnable { private int i; public Task(int i) { this.i = i; } /*所谓的task,本质就是一段需要被执行的代码*/ @Override public void run() { System.out.println(Thread.currentThread().getName() + " Task " + i); try { //任务是阻塞的,所以线程池的4个线程全占用了;任务队列也占用了 System.in.read(); } catch (IOException e) { e.printStackTrace(); } }
然后主线程中就可以愉快地给线程池添加task去执行了:
for (int i = 0; i < 8; i++) {//线程池执行8个线程 tpe.execute(new Task(i)); }
整个过程地逻辑还是很简单、清晰和明了的!图示如下:
有m个的thread去执行n个task;task被放在queue等待thread取出来执行!根据不同取task的策略又分成了:
- ScheduledThreadPool:指定时间间隔执行task
- workStealingPool:某个thread执行完自己的task后,可以取其他thread的task来执行,所以叫”steal“
- SingleThreadExecutor:只有一个线程的线程池,这种池子还有必要存在么?当然有了,单线程可以避免线程context切换带来的性能损耗,还能按照既定的顺序执行task
- cachedThreadPool:如果task的数量一会多、一会少,有明显的波峰波谷,适合这种类型的threadPool
- FixThreadPool:如果task数量比较恒定,没有明显的波峰波谷,比较适合这种类型的Pool
(3)学过操作系统或汇编指令的同学都了解:
- 所谓的线程或进程:在操作系统底层就是个结构体struct,记录了当前线程的状态(ready、running、suspend、stop等)、线程当前的context、stack大小、创建时间等关键信息!
- 线程或进程切换看起来很”高大上“,其实底层的原理很简单:把当前所有寄存器找个内存块保存好(就是所谓的context保存),然后jmp到目标代码执行就行了!
最后要点总结如下:
参考:
1、https://github.com/alibaba/p3c/blob/master/Java%E5%BC%80%E5%8F%91%E6%89%8B%E5%86%8C(%E9%BB%84%E5%B1%B1%E7%89%88).pdf alibaba的java开发手册
2、https://github.com/bjmashibing/JUC 马老师的多线程和高并发代码
3、https://blog.51cto.com/u_15668812/5349418 主线程等待子线程结束