PXM的JAVA并发编程学习总结
进程与线程
进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。
在 Java 中,当我们启动 main 函数时其实就是启动了一个 JVM 的进程,而 main 函数所在的线程就是这个进程中的一个线程,也称主线程。
线程与进程类似,但是是一个更小的执行单位,一个进程运行时可以产生多个线程,但是同一个进程的线程之间会共享堆和方法区,而进程与进程之间时相互隔离的。
所以系统创建和运行线程的开销比进程小很多。
Java 线程在运行的生命周期中的指定时刻只可能处于下面 6 种不同状态的其中一个状态:
- NEW: 初始状态,线程被创建出来但没有被调用 start() 。
- RUNNABLE: 运行状态,线程被调用了 start()等待运行的状态。
- BLOCKED:阻塞状态,需要等待锁释放。
- WAITING:等待状态,表示该线程需要等待其他线程做出一些特定动作(通知或中断)。
- TIME_WAITING:超时等待状态,可以在指定的时间后自行返回而不是像 WAITING 那样一直等待。
- TERMINATED:终止状态,表示该线程已经运行完毕
volatile
volatile关键字能够保证变量的可见性,如果声明了volatile关键字,每次都会从主内存中获取变量。本质原因时它禁用了cpu缓存,因为cpu缓存中的L1,L2级缓存时不共享的,所以才会造成同一个变量在不同线程读到的值可能不一致,因此禁用,让它们全部实时对共享的主内存进行修改即可。还有一个重要的特性就是volatile通过内存屏障,能够防止指令重排。这个的主要应用参考单例模式双重检查锁,如果有一个线程发现单例已经不为null了,并开始调用,如果没加volatile,那么可能由于指令重排,这个单例的创建变成了先建立引用,再初始化,此时由于初始化尚未完成,容易引发未知问题。当然,它不能保证操作的原子性,进行并发写的时候仍然会出问题。
synchronized
synchronized可以保证用它修饰的方法或者代码块同时只有一个线程在运行,对于语句块,内部基于monitor相关指令和对象头实现,monitor是JVM的内置锁,通过争抢monitor来确保只有一个线程能进入。对于方法,用的则是ACC_SYNCHRONIZED,用于标记这个方法是一个同步方法。
synchronized会随着锁竞争的加剧自动进行锁升级,因为在低竞争环境下,中止线程并唤醒的成本显然较高,轻量级锁更优,在高竞争环境下,显然让大量线程自旋等待容易导致资源被耗尽。因此,锁会经历如下过程:无锁->偏向锁->轻量锁->自旋锁,在后续版本(jdk15),偏向锁逐渐被淘汰了,由于现在的锁往往是多线程竞争而不是同一线程反复调用,因此偏向锁往往是负优化。
- volatile 关键字是线程同步的轻量级实现,所以 volatile性能肯定比synchronized关键字要好 。但是 volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块 。
- volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。
- volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。
ReentrantLock
ReentrantLock是一个可重入的独占锁,是对AQS框架的一个实现。相比syncornized增加了轮询、超时、中断、公平锁和非公平锁等高级功能,通过对AQS的state变量进行维护实现可重入。和syncornized不同的地方在于它有更多高级功能,并且是JDK层面的锁,而syncornized是由JVM实现的。唯一需要注意的是它要显式调用lock() 和 unlock(),使用不当可能产生死锁,一般通过try-catch调用,并放在finally里面解锁。
Threadlocal
Threadlocal类可以让每个线程读取同一个对象的同一个属性,都有相互独立的不同副本,可以避免数据竞争问题,它的核心实现逻辑在于通过把Threadlocal本身的引用和要被传入的属性组成一对Entry,其中Threadlocal本身的虚引用是Key,被传入的属性是value。因为Key是弱引用,所以当发生gc的时候会把它回收,所以如果没有了其它Threadlocal强引用,进行gc的时候会将Key也就是Threadlocal给回收掉。这样就不会导致污染后续使用者了。
Threadlocal有一个内部类叫做threadlocalMap,其中Threadlocal产生的entry就存在这里,每个线程的这个是唯一的,通过Threadlocal对象就能查找到当前线程的Threadlocal修饰的数据。
Threadlocal虽然很好,但是也有一些缺点。诸如内存泄露,因此你最好在执行结束的时候释放所有和当前线程有关的Threadlocal,必须通过try-catch调用并在finally代码块中手动调用remove。如果是公有的Threadlocal,最好加上final static关键字防止threadlocal被多次实例化。
Threadlocal和公共的ConcurrentHashMap最大的不同就是Threadlocal只负责储存私有信息,ConcurrentHashMap会共享给所有对象。
线程池
推荐使用ThreadPoolExecutor进行实现,构造参数如下:
int corePoolSize,//线程池的核心线程数量 int maximumPoolSize,//线程池的最大线程数 long keepAliveTime,//当线程数大于核心线程数时,多余的空闲线程存活的最长时间 TimeUnit unit,//时间单位 BlockingQueue workQueue,//任务队列,用来储存等待执行任务的队列 ThreadFactory threadFactory,//线程工厂,用来创建线程,一般默认即可 RejectedExecutionHandler handler//拒绝策略,当提交的任务过多而不能及时处理时,我们可以定制策略来处理任务
核心线程数量就是线程池中一直保持着的线程数,即使没有请求它们也仍然会继续等待,防止突然来请求了大量增开线程导致阻塞。
最大线程数就是在请求变多,并且任务队列也放不下了以后,线程池扩容的最大线程数。
存活时间和时间单位会在线程扩容后,若线程空闲超过一定时长就回收它们,直到等于核心线程数。
任务队列会存储即将进行的任务,线程在上一个任务完成后会向这个队列的头部进行拉取,如果要根据权重排优先级可以改成PriorityBlockingQueue。队列也可分为有界和无界的,如果是有界的就会在装不下了之后触发拒绝策略。
线程工厂用于创建新线程,可以自己定义属性,如定制线程名称,守护线程,设置优先级等,基本上都要自定义,默认的调试较为困难。
拒绝策略用于处理任务队列丢弃的数据,常见的有直接丢弃,让原线程运行,重试等。
CompletableFuture
这是一个能够满足异步任务需求的执行器,通过默认或者自定义的线程池来异步执行耗时任务,并且可以实现任务的串行运行,相比Future.get()方式它不会阻塞主线程,还拥有优雅的链式调用,错误处理等机制。
CompletableFuture.supplyAsync(() -> { // 数据库查询或外部API调用 return fetchFromDB(); }, taskExecutor);
对于IO密集型任务,如果同步阻塞调用会导致吞吐量非常低,把这个任务交给任务线程池就能释放tomcat线程,从而大大提升吞吐量,常用于sql慢查询,AI对话接口调用。不少开源框架的任务调度系统都是基于这个来实现的。