场景:对于逻辑复杂并且大批量数据生成(eg:定时任务生成月收入账单),数据统计(eg:某些数据报表) 如何提升效率。

目标:一个php需要执行4个小时的复杂脚本,改造到java系统后想要在半小时内完成。

思路:

  • 利用分布式架构:可以按某种维度(类型 || 主体 || ...)做到逻辑和数据隔离的,充分利用分布式系统的优势:
    eg:利用消息中间件(RabbitMq等)将需要处理的主体分批发送,在多台机器的消费者中各自处理一批。 例如生成10万个主体的账单,将所有subject先按200进行分片,分成500个消息进行投递。每台机器5个消费者,n台机器最多可以同时进行5*n个消费者共同处理。
  • 使用线程池:使用java本身提供的并发编程方式(多线程)进行批量处理:
    使用线程池场景:当批量处理数据后需要进行数据收集,如统一生成到一个报表中,此时如果用mq没有承载结果数据的地方,无法进行这种临时性的收集。就可以考虑使用线程池
    弊端:因为该任务必然只能在单台机器上执行,丧失了分布式系统的优势。

先来点前菜(无知引起)

属于生产环境遇到的一个现象:

先获取到20个id,每片200个id,分片循环发消息。 为防止消息队列积压(积压也问题不大),在循环中采用Thread.sleep(5000) 去控制消息发送速度。

1
2
3
4
5
6
7
8
9
10
List<List<SubjectInfoVO>> staticVOList = Lists.partition(subjectInfoVOList, partMaxSend);
staticVOList.forEach(subjectInfoVOS -> {
    publishMessage(subjectInfoVOS);
    try {
         Thread.sleep(5000);
    } catch (Exception e) {
         log.error(e.getMessage());
         Thread.currentThread().interrupt();
    }
});

 

当发现太慢了,发了一部分之后想停掉脚本,导致的现象:瞬间把消息全部发出去了,sleep失效,队列积压。又因为消费者多(消费者中还用了线程池,别这么干),导致消费过快,GC太多,导致cpu负载达到1.4左右

 

原因

 

如果一个线程处于了阻塞状态(如线程调用了thread.sleep、thread.join、thread.wait、1.5中的condition.await、以及可中断的通道上的 I/O 操作方法后可进入阻塞状态),则在线程在检查中断标示时如果发现中断标示为true,则会在这些阻塞方法(sleep、join、wait、1.5中的condition.await及可中断的通道上的 I/O 操作方法)调用处抛出InterruptedException异常,并且在抛出异常后立即将线程的中断标示位清除,即重新设置为false。抛出异常是为了线程从阻塞状态醒过来,并在结束线程前让程序员有足够的时间来处理中断请求。

 
没有任何语言方面的需求一个被中断的线程应该终止。中断一个线程只是为了引起该线程的注意,被中断线程可以决定如何应对中断。某些线程非常重要,以至于它们应该不理会中断,而是在处理完抛出的异常之后继续执行,但是更普遍的情况是,一个线程将把中断看作一个终止请求,这种线程的run方法遵循如下形式:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void run() {
    try {
        ...
        /*
         * 不管循环里是否调用过线程阻塞的方法如sleep、join、wait,这里还是需要加上
         * !Thread.currentThread().isInterrupted()条件,虽然抛出异常后退出了循环,显
         * 得用阻塞的情况下是多余的,但如果调用了阻塞方法但没有阻塞时,这样会更安全、更及时。
         */
        while (!Thread.currentThread().isInterrupted()&& more work to do) {
            do more work
        }
    } catch (InterruptedException e) {
        //线程在wait或sleep期间被中断了
    } finally {
        //线程结束前做一些清理工作
    }
}

上面是while循环在try块里,如果try在while循环里时,因该在catch块里重新设置一下中断标示,因为抛出InterruptedException异常后,中断标示位会自动清除,此时应该这样:

1
2
3
4
5
6
7
8
9
10
public void run() {
    while (!Thread.currentThread().isInterrupted()&& more work to do) {
        try {
            ...
            sleep(delay);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();//重新设置中断标示
        }
    }
}

具体问题步骤

step1、xxljob停止脚本时,xxljob后台发一个15的信号

step2、线程将中断标志设置为true,此时阻塞(sleep)状态收到该信号,抛出InterruptedException异常,并且重新将中断标志设置为false。

step3、catch到该异常,并重新将中断标志设置为true

step4、进入下一个循环。。。直到循环结束

 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
for (List<SubjectInfoVO> subjects : staticVOList) {
  
    publishMessage(subjects);
  
    try {
        Thread.sleep(5000);
    } catch (Exception e) {
        log.error("分片发消息异常", e);
        Thread.currentThread().interrupt();
        return false;
    }
}
  
//或者
for (List<SubjectInfoVO> subjects : staticVOList) {
    if (!Thread.currentThread().isInterrupted()) {
        try {
            publishMessage(subjects);
            Thread.sleep(5000);
        } catch (Exception e) {
            log.error("分片发消息异常", e);
            Thread.currentThread().interrupt();
        }
    }
}

 

 
 
 

 

 

 

线程池

在阿里的《Java 开发手册》中提到

 

 

构造函数

ThreadPoolExecutor 类一共提供了四个构造方法,我们基于参数最完整构造方法了解一下线程池创建所需要的变量:

1
2
3
4
5
6
7
8
public ThreadPoolExecutor(int corePoolSize, // 核心线程数
                          int maximumPoolSize, // 最大线程数
                          long keepAliveTime, // 非核心线程闲置存活时间
                          TimeUnit unit, // 时间单位
                          BlockingQueue<Runnable> workQueue, // 工作队列
                          ThreadFactory threadFactory, // 创建线程使用的线程工厂
                          RejectedExecutionHandler handler // 拒绝策略) {
}
  • 核心线程数:即长期存在的线程数,当线程池中运行线程未达到核心线程数时会优先创建新线程; 

  • 最大线程数:当核心线程已满,工作队列已满,同时线程池中线程总数未超过最大线程数,会创建非核心线程; 

  • 非核心线程闲置存活时间:当非核心线程闲置的时的最大存活时间; 

  • 时间单位:非核心线程闲置存活时间的时间单位; 

  • 任务队列:当核心线程满后,任务会优先加入工作队列,等等待核心线程消费; 

  • 线程工厂:线程池创建新线程时使用的线程工厂; 

  • 拒绝策略:当工作队列与线程池都满时,用于执行的策略; 

 

线程池的三种队列区别:SynchronousQueue、LinkedBlockingQueue 和ArrayBlockingQueue 

 

SynchronousQueue

SynchronousQueue Collapse source
1
private static ExecutorService cachedThreadPool = new ThreadPoolExecutor(4, Runtime.getRuntime().availableProcessors() * 20, TimeUnit.MILLISECONDS, new SynchronousQueue<>(), r -> new Thread(r, "ThreadTest"));

 

SynchronousQueue没有容量,是无缓冲等待队列,是一个不存储元素的阻塞队列,会直接将任务交给消费者,必须等队列中的添加元素被消费后才能继续添加新的元素。 

拥有公平(FIFO)和非公平(LIFO)策略,非公平策略会导致一些数据永远无法被消费的情况? 

使用SynchronousQueue阻塞队列一般要求maximumPoolSizes为无界(Integer.MAX_VALUE),避免线程拒绝执行操作。 

 

 

LinkedBlockingQueue

LinkedBlockingQueue Collapse source
1
private static ExecutorService cachedThreadPool = new ThreadPoolExecutor(4, Runtime.getRuntime().availableProcessors() * 20, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(), r -> new Thread(r, "ThreadTest"));

LinkedBlockingQueue是一个无界缓存等待队列。当前执行的线程数量达到corePoolSize的数量时,剩余的元素会在阻塞队列里等待。(所以在使用此阻塞队列时maximumPoolSizes就相当于无效了),每个线程完全独立于其他线程。生产者和消费者使用独立的锁来控制数据的同步,即在高并发的情况下可以并行操作队列中的数据。 

注:这个队列需要注意的是,虽然通常称其为一个无界队列,但是可以人为指定队列大小,而且由于其用于记录队列大小的参数是int类型字段,所以通常意义上的无界其实就是队列长度为 Integer.MAX_VALUE,且在不指定队列大小的情况下也会默认队列大小为 Integer.MAX_VALUE,等同于如下: 

 

 

ArrayBlockingQueue

ArrayBlockingQueue Collapse source
1
private static ExecutorService cachedThreadPool = new ThreadPoolExecutor(4, Runtime.getRuntime().availableProcessors() * 20, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(32), r -> new Thread(r, "ThreadTest"));

 

ArrayBlockingQueue是一个有界缓存等待队列,可以指定缓存队列的大小,当正在执行的线程数等于corePoolSize时,多余的元素缓存在ArrayBlockingQueue队列中等待有空闲的线程时继续执行,当ArrayBlockingQueue已满时,加入ArrayBlockingQueue失败,会开启新的线程去执行,当线程数已经达到最大的maximumPoolSizes时,再有新的元素尝试加入ArrayBlockingQueue时会报错。 

 

线程池的中断方法: 

 

shutdown():中断线程池,不再添加新任务,同时等待当前进行和队列中的任务完成; 

shutdownNow():立即中断线程池,不再添加新任务,同时中断所有工作中的任务,不再处理任务队列中任务。 

 

线程自定义属性子线程拿不到问题

eg:当子线程需要继承父线程单独header头中信息(eg:放ThreadLocal中的信息, 子线程拿不到)
解决方法:

1、将父线程中的值拿出来重新给子线程赋值一次

2、阿里开源的TTL线程池

 

ThreadLocal

ThreadLocal是线程Thread中属性threadLocals的管理者。

使用场景:

典型场景1: 每个线程需要一个独享的对象(通常是工具类,典型需要使用的类有SimpleDateFormat和Random)

典型场景2: 每个线程内需要保存全局变量(例如在拦截器中获取用户信息),可以让不同方法直接使用,避免参数传递的麻烦。

 

思考

在不看源码之前,我们思考下如果让我们设计这样一个工具类,能够使得线程间的变量相互隔离,我们会怎样设计?

每一个线程,其执行均是依靠Thread类的实例的start方法来启动线程,然后CPU来执行线程。每一个Thread类的实例的运行即为一个线程。若要每个线程(每个Thread实例)的变量空间隔离,则需要将这个变量的定义声明在Thread这个类中。这样,每个实例都有属于自己的这个变量的空间,则实现了线程的隔离。事实上,ThreadLocal的源码也是这样实现的。

实现线程隔离的原理

在Thread类中声明一个公共的类变量ThreadLocalMap,用以在Thread的实例中预占空间

ThreadLocal.ThreadLocalMap threadLocals = null;

在ThreadLocal中创建一个内部类ThreadLocalMap,这个Map的key是ThreadLocal对象,value是set进去的ThreadLocal中泛型类型的值

private void set(ThreadLocal key, Object value) {...}

在new ThreadLocal时,只是简单的创建了个ThreadLocal对象,与线程还没有任何关系,真正产生关系的是在向ThreadLocal对象中set值得时候:
1.首先从当前的线程中获取ThreadLocalMap,如果为空,则初始化当前线程的ThreadLocalMap
2.然后将值set到这个Map中去,如果不为空,则说明当前线程之前已经set过ThreadLocal对象了。
这样用一个ThreadHashMap来存储当前线程的若干个可以线程间隔离的变量,key是ThreadLocal对象,value是要存储的值(类型是ThreadLocal的泛型)

public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
    }

 


从ThreadLocal中获取值 :还是先从当前线程中获取ThreadLocalMap,然后使用ThreadLocal对象(key)去获取这个对象对应的值(value)

复制代码
public T get() {
       Thread t = Thread.currentThread();
       ThreadLocalMap map = getMap(t);
       if (map != null) {
           ThreadLocalMap.Entry e = map.getEntry(this);
           if (e != null) {
               @SuppressWarnings("unchecked")
               T result = (T)e.value;
               return result;
           }
       }
       return setInitialValue();
   }
复制代码

 


到这里,如果仅仅是理解ThreadLocal是如何实现的线程级别的隔离已经完全足够了。简单的讲,就是在Thread的类中声明了ThreadLocalMap这个类,然后在使用ThreadLocal对象set值的时候将当前线程(Thread实例)进行map初始化,并将Threadlocal对应的值塞进map中,下次get的时候,也是使用这个ThreadLcoal的对象(key)去从当前线程的map中获取值(value)就可以了

 

ThreadLocalMap的深究

从源码上看,ThreadLocalMap虽然叫做Map,但和我们常规理解的Map不太一样,因为这个类并没有实现Map这个接口,只是定义在ThreadLocal中的一个静态内部类。只是因为在存储的时候也是以key-value的形式作为方法的入参暴露出去,所以称为map。

1
2
3
4
5
static class ThreadLocalMap {...}
//ThreadLocalMap的创建,在使用ThreadLocal对象set值的时候,会创建ThreadLocalMap的对象,可以看到,入参就是KV,key是ThreadLocal对象,value是一个Entry对象,存储kv(HashMap是使用Node作为KV对象存储)。Entry的key是ThreadLocal对象,vaule是set进去的具体值。
void createMap(Thread t, T firstValue) {
    t.threadLocals = new ThreadLocalMap(this, firstValue);
}

继续看看在创建ThreadLocalMap实例的时候做了什么?其实ThreadLocalMap存储是一个Entry类型的数组,key提供了hashcode用来计算存储的数组地址(散列法解决冲突)
创建Entry数组(初始容量16)
然后获取到key(ThreadLocal对象)的hashcode(是一个自增的原子int型)

 Expand source
 

使用【hashcode 模(%) 数组长度】的方式得到要将key存储到数组的哪一位。
设置数组的扩容阈值,用以后续扩容

 Expand source
 

创建ThreadLcoalMap对象只有在当前线程第一次插入kv的时候发生,如果是第二次插入kv,则会进行第三步

这个set的过程其实就是根据ThreadLocal的hashcode来计算存储在Entry数组的位置
利用ThreadLocal的【hashcode 模(%) 数组长度】的方式获取存储在数组的位置
如果当前位置已存在值,则向右移一位,如果也存在值,则继续右移,直到有空位置出现为止
将当前的value存储上面两部得到的索引位置(上面这两步就是散列法的实现)
校验是否扩容,如果当前数组的中存储的值得数量大于阈值(数组长度的2/3),则扩容一倍,并将原来的数组的值重新hash至新数组中(这个过程其实就是HashMap的扩容过程)

 Expand source
 

 

❗️注意:如果一定需要用,一定要记得使用完在finally {}中 remove()。 原因:因线程池中的线程会被复用,threadlocal如果不清除,下一次的请求中threadlocal中会有错误的初始值(eg:IM数据库事物提交被拒绝事件)

 

接触感受:TreadLocal本身好用!但是需要正确使用,不正规的使用可能会产生内存泄漏等问题。

参考:https://mp.weixin.qq.com/s/WcEbLtegeFOplIhjgn6QdA

部分场景的替代方案(仅限于变量一直在一个类中使用,使用线程安全的map):  ConcurrentHashMap

众所周知,在 Java 中,HashMap 是非线程安全的,如果想在多线程下安全的操作 map,主要有以下解决方法:

  • 第一种方法,使用Hashtable线程安全类;
  • 第二种方法,使用Collections.synchronizedMap方法,对方法进行加同步锁;
  • 第三种方法,使用并发包中的ConcurrentHashMap类;

Hashtable 是一个线程安全的类,Hashtable 几乎所有的添加、删除、查询方法都加了synchronized同步锁!相当于给整个哈希表加了一把大锁,在竞争激烈的多线程场景中性能就会非常差,所以 Hashtable 不推荐使用!

Collections.synchronizedMap,本质也是对 HashMap 进行全表锁!不推荐使用!

ConcurrentHashMap 类所采用的正是分段锁的思想,将 HashMap 进行切割,把 HashMap 中的哈希数组切分成小数组,每个小数组有 n 个 HashEntry 组成,其中小数组继承自ReentrantLock(可重入锁),这个小数组名叫Segment。

JDK1.8 中的 ConcurrentHashMap。随着HashMap 引入了红黑二叉树设计,当冲突的链表长度大于 8 时,会将链表转化成红黑二叉树结构,红黑二叉树又被称为平衡二叉树,在查询效率方面,又大大的提高了不少。

它抛弃了原有的 Segment 分段锁实现,采用了 CAS + synchronized 来保证并发的安全性。

参考:https://mp.weixin.qq.com/s/B1XboYOpGfnIOtCUvmOSmA

 

CopyOnWriteArrayList与Collections.synchronizedList

线程安全的list,当需要收集用线程池跑完的数据时,需要用到线程安全的list。eg:生成报表

用:Collections.synchronizedList(new ArrayList<ReportBeforeCarryVO>());

 

CopyOnWriteArrayList和Collections.synchronizedList是实现线程安全的列表的两种方式。两种实现方式分别针对不同情况有不同的性能表现,其中CopyOnWriteArrayList的写操作性能较差,而多线程的读操作性能较好。而Collections.synchronizedList的写操作性能比CopyOnWriteArrayList在多线程操作的情况下要好很多,而读操作因为是采用了synchronized关键字的方式,其读操作性能并不如CopyOnWriteArrayList。因此在不同的应用场景下,应该选择不同的多线程安全实现类。

参考:https://mp.weixin.qq.com/s/tvqeBmJg2WAxuwn2C1VAag

 

并行流(xxx.parallelStream() || xxx.stream().parallel())

并行流如此方便,它的线程从那里来呢?有多少个?怎么配置呢?

并行流内部使用了默认的 ForkJoinPool 线程池。默认的线程数量就是处理器的核心数,而配置系统核心属性:java.util.concurrent.ForkJoinPool.common.parallelism 可以改变线程池大小。

不过该值是全局变量。改变他会影响所有并行流。目前还无法为每个流配置专属的线程数。一般来说采用处理器核心数是不错的选择

使用场景:数据量多且用cpu计算多的时候

如果处理的代码块中存在I/O,不建议用并行流,请选择”线程池“。

 

附录:

java引用类型

引用类型 对象是否可饮用 回收时间 应用场景
强引用 可以 一直存活,除非GC Roots不可达 所有程序的场景,基本对象,自定义对象等
软引用 可以 内存不足时会被回收 一般用在对内存非常敏感的资源上,用作缓存的场景比较多,例如:网页缓存、图片缓存等
弱引用 可以 下一次GC 生命周期很短的对象,如ThreadLocal中的key
虚引用 不可以 下一次GC,不影响对象的生命周期
(随时会被回收,创建了可能很快会被回收)
必须和引用队列(ReferenceQueue)一起使用,一般用于追踪垃圾收集器的回收动作。相比对象的finalize方法,虚引用的方式更加灵活和安全。
(可能被JVM团队内部用来跟踪JVM的垃圾回收活动)