场景:对于逻辑复杂并且大批量数据生成(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。抛出异常是为了线程从阻塞状态醒过来,并在结束线程前让程序员有足够的时间来处理中断请求。
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没有容量,是无缓冲等待队列,是一个不存储元素的阻塞队列,会直接将任务交给消费者,必须等队列中的添加元素被消费后才能继续添加新的元素。
拥有公平(FIFO)和非公平(LIFO)策略,非公平策略会导致一些数据永远无法被消费的情况?
使用SynchronousQueue阻塞队列一般要求maximumPoolSizes为无界(Integer.MAX_VALUE),避免线程拒绝执行操作。
LinkedBlockingQueue
LinkedBlockingQueue是一个无界缓存等待队列。当前执行的线程数量达到corePoolSize的数量时,剩余的元素会在阻塞队列里等待。(所以在使用此阻塞队列时maximumPoolSizes就相当于无效了),每个线程完全独立于其他线程。生产者和消费者使用独立的锁来控制数据的同步,即在高并发的情况下可以并行操作队列中的数据。
注:这个队列需要注意的是,虽然通常称其为一个无界队列,但是可以人为指定队列大小,而且由于其用于记录队列大小的参数是int类型字段,所以通常意义上的无界其实就是队列长度为 Integer.MAX_VALUE,且在不指定队列大小的情况下也会默认队列大小为 Integer.MAX_VALUE,等同于如下:
ArrayBlockingQueue
ArrayBlockingQueue是一个有界缓存等待队列,可以指定缓存队列的大小,当正在执行的线程数等于corePoolSize时,多余的元素缓存在ArrayBlockingQueue队列中等待有空闲的线程时继续执行,当ArrayBlockingQueue已满时,加入ArrayBlockingQueue失败,会开启新的线程去执行,当线程数已经达到最大的maximumPoolSizes时,再有新的元素尝试加入ArrayBlockingQueue时会报错。
线程池的中断方法:
shutdown():中断线程池,不再添加新任务,同时等待当前进行和队列中的任务完成;
shutdownNow():立即中断线程池,不再添加新任务,同时中断所有工作中的任务,不再处理任务队列中任务。
线程自定义属性子线程拿不到问题
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型)
使用【hashcode 模(%) 数组长度】的方式得到要将key存储到数组的哪一位。
设置数组的扩容阈值,用以后续扩容
创建ThreadLcoalMap对象只有在当前线程第一次插入kv的时候发生,如果是第二次插入kv,则会进行第三步
这个set的过程其实就是根据ThreadLocal的hashcode来计算存储在Entry数组的位置
利用ThreadLocal的【hashcode 模(%) 数组长度】的方式获取存储在数组的位置
如果当前位置已存在值,则向右移一位,如果也存在值,则继续右移,直到有空位置出现为止
将当前的value存储上面两部得到的索引位置(上面这两步就是散列法的实现)
校验是否扩容,如果当前数组的中存储的值得数量大于阈值(数组长度的2/3),则扩容一倍,并将原来的数组的值重新hash至新数组中(这个过程其实就是HashMap的扩容过程)
❗️注意:如果一定需要用,一定要记得使用完在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的垃圾回收活动) |
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· AI与.NET技术实操系列:基于图像分类模型对图像进行分类
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· 25岁的心里话
· ollama系列01:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现