从阿里开发规范中学习高并发

从阿里开发规范中学习高并发

前言

阿里巴巴的开发规范中有一章是专门讲述如何优雅的处理并发的。其中对于如何保证线程安全、如何正确的创建线程池等都有更深层次的讲解。下面用代码结合理论来说说我自己的理解。

创建线程和线程池的规范

1.创建线程或线程池时请指定有意义的线程名称,方便出错时回溯。

public class TimerTaskThread extends Thread {     
	 public TimerTaskThread() {         
	  	super.setName("TimerTaskThread");   
	 	 ...  
	  }              
 }

2.线程资源必须通过线程池提供,不允许在应用中自行显式创建线程。
说明:使用线程池的好处是减少在创建和销毁线程上所消耗的时间以及系统资源的开销,解决资源不足的问题。如果不使用线程池,有可能造成系统创建大量同类线程而导致消耗完内存或者“过度切换”的问题。

3.创建线程池的正确姿势
线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,并且给各个参数初始值。Executors 返回的线程池对象的弊端如下:
1)FixedThreadPool 和 SingleThreadPool: 允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。
2)CachedThreadPool 和 ScheduledThreadPool: 允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM。

接下来说说ThreadPoolExecutor的构造方法中各个参数的含义。
corePoolSize 核心线程数
在创建了线程池后,默认情况下,线程池中并没有任何线程,而是等待有任务到来才创建线程去执行任务,(除非调用了prestartAllCoreThreads()或者prestartCoreThread()方法,从这2个方法的名字就可以看出,是预创建线程的意思,即在没有任务到来之前就创建corePoolSize个线程或者一个线程)。当线程池中的线程数目达到corePoolSize后,就会把到达的任务放到缓存队列当中。核心线程在allowCoreThreadTimeout被设置为true时会超时退出,默认情况下不会退出。

maxPoolSize 线程池最大线程数量
当线程数大于或等于核心线程,且任务队列已满时,线程池会创建新的线程,直到线程数量达到maxPoolSize。如果线程数已等于maxPoolSize,且任务队列已满,则已超出线程池的处理能力,线程池会拒绝处理任务而抛出异常。当然,也可以通过设置线程池拒绝处理任务策略不抛出异常。

keepAliveTime 线程存活时间 - 空闲时间达到阈值退出
当线程空闲时间达到keepAliveTime,该线程会退出,直到线程数量等于corePoolSize。如果allowCoreThreadTimeout设置为true,则所有线程空闲时间达到keepAliveTime均会退出直到线程数量为0。

allowCoreThreadTimeout
是否允许核心线程空闲退出,默认值为false。

queueCapacity 任务队列容量
从maxPoolSize的描述上可以看出,任务队列的容量会影响到线程的变化,因此任务队列的长度也需要恰当的设置。

workQueue 阻塞队列
用来存储等待执行的任务,这个参数的选择也很重要,会对线程池的运行过程产生重大影响,一般来说,这里的阻塞队列有以下几种选择:
ArrayBlockingQueue
LinkedBlockingQueue
SynchronousQueue
PriorityBlockingQueue
ArrayBlockingQueue和PriorityBlockingQueue使用较少,一般使用LinkedBlockingQueue和Synchronous。线程池的排队策略与BlockingQueue有关。

handler 表示当拒绝处理任务时的策略
有以下四种取值:
ThreadPoolExecutor.AbortPolicy:丢弃任务并抛出RejectedExecutionException异常。
ThreadPoolExecutor.DiscardPolicy:也是丢弃任务,但是不抛出异常。
ThreadPoolExecutor.DiscardOldestPolicy:丢弃队列最前面的任务,然后重新尝试执行任务(重复此过程)
ThreadPoolExecutor.CallerRunsPolicy:由调用线程处理该任务

下面用代码来示范下各个参数的使用

public static void main(String[] args) {
        // 核心线程数量
        int corePoolSize = 10;
        // 线程池最大线程数量
        int maximumPoolSize = 10;
        // 线程存活时间 - 空闲时间达到阈值退出
        long keepAliveTime = 60L;
        // 任务队列容量
        BlockingQueue linkedBlockingQueue = new LinkedBlockingQueue(1);
        // 拒绝处理任务时的策略 - 丢弃任务,但是不抛出异常
        RejectedExecutionHandler handler = new ThreadPoolExecutor.DiscardPolicy();
        // 线程池初始化
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(corePoolSize, maximumPoolSize,
                keepAliveTime, TimeUnit.SECONDS, linkedBlockingQueue, handler);
        // 分配任务
        for (int i = 0; i < 13; i++) {
            Runnable runnable = new Runnable() {
                @Override
                public void run() {
                    System.out.println(Thread.currentThread().getName());
                }
            };
            threadPoolExecutor.execute(runnable);
        }
        threadPoolExecutor.shutdown();
    }

线程池最大线程数量为10,等待任务队列的容量为1,而任务数为13,已经超过了最大线程数量加任务队列的容量之和,照常来说该代码的运行的结果是抛异常的,但线程池拒绝处理任务时的策略是丢弃任务,不抛出异常,所以main主线程能照常运行不会抛出异常。

如何使用锁

高并发时,同步调用应该去考量锁的性能损耗
能用无锁数据结构,就不要用锁;能锁区块,就不要锁整个方法体;能用对象锁,就不要用类锁。 尽可能使加锁的代码块工作量尽可能的小,避免在锁代码块中调用 RPC 方法。

多资源加锁规范
对多个资源、数据库表、对象同时加锁时,需要保持一致的加锁顺序,否则可能会造成死锁。线程一需要对表 A、B、C 依次全部加锁后才可以进行更新操作,那么线程二的加锁顺序也必须是 A、B、C,否则可能出现死锁。

锁的选择
并发修改同一记录时,避免更新丢失,需要加锁。要么在应用层加锁,要么在缓存加锁,要么在数据库层使用乐观锁,使用 version 作为更新依据。如果每次访问冲突概率小于20%,推荐使用乐观锁,否则使用悲观锁。乐观锁的重试次数不得小于3次。

读写锁
分为读锁和写锁,多个读锁不互斥,读锁与写锁互斥,由Java虚拟机控制。如果代码允许很多线程同时读,但不能同时写,就上读锁;如果代码不允许同时读,并且只能有一个线程在写,就上写锁。读写锁的接口是ReadWriteLock,具体实现类是 ReentrantReadWriteLock。synchronized属于互斥锁,任何时候只允许一个线程的读写操作,其他线程必须等待;而ReadWriteLock允许多个线程获得读锁,但只允许一个线程获得写锁,效率相对较高一些。下面用代码来示范下

public class ReadWriteLockTest {

    static int count = 0;

    public static void addCount() {
        // 上锁
        Lock writeLock = Locker.INSTANCE.writeLock();
        writeLock.lock();
        count++;
        // 释放锁
        writeLock.unlock();
    }

    public static void main(String[] args) {
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(10, 1000,
                60L, TimeUnit.SECONDS,
                new ArrayBlockingQueue<Runnable>(10));
        for (int i = 0; i < 1000; i++) {
            Runnable runnable = new Runnable() {
                @Override
                public void run() {
                    ReadWriteLockTest.addCount();
                }
            };
            threadPoolExecutor.execute(runnable);
        }
        threadPoolExecutor.shutdown();
        System.out.println(ReadWriteLockTest.count);
    }
}

enum Locker {
    INSTANCE;

    private static final ReadWriteLock lock = new ReentrantReadWriteLock();

    public Lock writeLock() {
        return lock.writeLock();
    }
}

上述代码中,新建一个线程池,线程池中线程会对count进行加一的操作,如果不加锁,最后输出的count肯定不会等于1000,往往了996或者997。因为多线程进行读写操作时,i变量是不稳定状态,当一个线程还没执行count++时,另外一个线程执行i++时,可能i已经大于1000了,便跳出了循环,执行shutdown。

注意事项

1.在高并发场景中,避免使用”等于”判断作为中断或退出的条件。如果并发控制没有处理好,容易产生等值判断被“击穿”的情况,使用大于或小于的区间判断条件来代替。
2.HashMap 在容量不够进行 resize 时由于高并发可能出现死链,导致 CPU 飙升,在 开发过程中可以使用其它数据结构或加锁来规避此风险。
3.避免 Random 实例被多线程使用,虽然共享该实例是线程安全的,但会因竞争同一 seed 导致的性能下降。

猜你喜欢

转载自blog.csdn.net/weixin_43776741/article/details/95615846
今日推荐