目录
线程池
线程池作用
线程的创建是比较消耗资源的,在计算机程序设计中,线程池是实现计算机程序并发执行的一种软件设计模式。
线程池维护多个线程,该模型提高了性能,避免了在执行过程中由于为短时任务频繁创建和销毁线程而造成资源紧张。
使用线程池可以带来以下好处:
- 降低资源消耗:通过池化技术重复利用已创建的线程,降低线程创建和销毁造成的损耗。
- 提高响应速度:任务到达时,无需等待线程创建即可立即执行。
- 提高线程的可管理性:线程是稀缺资源,如果无限制创建,不仅会消耗系统资源,还会因为线程的不合理分布导致资源调度失衡,降低系统的稳定性。使用线程池可以进行统一的分配、调优和监控。
- 提供更多更强大的功能:线程池具备可拓展性,允许开发人员向其中增加更多的功能。比如延时定时线程池ScheduledThreadPoolExecutor,就允许任务延期执行或定期执行
线程池用法
Java中的线程池核心实现类是ThreadPoolExecutor。
首先看一下ThreadPoolExecutor构造函数
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
corePoolSize - 池中所保存的线程数,包括空闲线程。
maximumPoolSize - 池中允许的最大线程数。
keepAliveTime - 当线程数大于核心时,此为终止前多余的空闲线程等待新任务的最长时间。
unit - keepAliveTime 参数的时间单位。
workQueue - 执行前用于保持任务的队列。此队列仅保持由 execute 方法提交的 Runnable 任务。
threadFactory - 执行程序创建新线程时使用的工厂。
handler - 由于超出线程范围和队列容量而使执行被阻塞时所使用的拒绝策略。
如图展示了Java线程池的工作流程。
无界队列注意事项
workQueue又分有界队列与无界队列。
ArrayBlockingQueue 是一个用数组实现的有界阻塞队列,LinkedBlockingQueue是一个基于链表的无界队列。
如果运行的线程等于或多于 corePoolSize,并且队列没有满,则 Executor 始终首选将请求加入队列,而不添加新的线程。对于无界队列来说,总是可以加入的。换句说,永远也不会触发产生新的线程!corePoolSize大小的线程数会一直运行,忙完当前的,就从队列中拿任务开始运行。如果没有给LinkedBlockingQueue指定容量大小,其默认值将是Integer.MAX_VALUE,如果存在添加速度大于删除速度时候,有可能会内存溢出,这点在使用前希望慎重考虑。
线程与协程
线程上下文切换
多任务系统往往需要同时执行多道作业.作业数往往大于机器的CPU数, 然而一颗CPU同时只能执行一项任务, 如何让用户感觉这些任务正在同时进行呢? 操作系统的设计者巧妙地利用了时间片轮转的方式, CPU给每个任务都服务一定的时间, 然后把当前任务的状态保存下来, 在加载下一任务的状态后, 继续服务下一任务. 任务的状态保存及再加载, 这段过程就叫做上下文切换. 时间片轮转的方式使多个任务在同一颗CPU上执行变成了可能, 但同时也带来了保存现场和加载现场的直接消耗。
主内存与工作内存
- Java 内存模型规定了所有的变量都存储在主内存 (Main Memory) 中。
- 每条线程还有自己的工作内存 (Working Memory),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程都变量的所有操作 (读取、赋值等) 都必须在工作内存中进行,而不能直接读写主内存中变量。
- 不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值得传递均需要通过主内存来完成,线程、主内存、工作内存三者的交互关系如下
协程
了解了线程上下文切换与主内存、工作内存后,我们对线程的缺点也算是有了大概的了解:Java线程使用操作系统层面的 thread,每一个 thread 都需要耗费静态的大量的内存,创建、切换、调度成本高昂,系统能容纳的线程数量也很有限。
即使 JVM 把 threads 带到了用户空间,它依然无法支持百万级别的 threads ,想象下在你的新的系统中,在 thread 间进行切换只需要耗费100 纳秒,即使只做上下文切换,有也只能使 100 万个 threads 每秒钟做 10 次上下文的切换,更重要的是,你必须要让你的 CPU 满负荷的做这样的事情。
-Xss:设置每个线程的堆栈大小.
JDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K.
更具应用的线程所需内存大小进行调整.
在相同物理内存下,减小这个值能生成更多的线程.
但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右.
当中断发生,从线程A切换到线程B去执行之前,操作系统首先要把线程A的上下文数据妥善保管好,然后把寄存器、内存分页等恢复到线程B挂起时候的状态,这样线程B被重新激活后才能仿佛从来没有被挂起过。这种保护和恢复现场的工作,免不了涉及一系列数据在各种寄存器、缓存中的来回拷贝,当然不可能是一种轻量级的操作。如果说内核线程的切换开销是来自于保护和恢复现场的成本,那如果改为采用用户线程,这部分开销就能够省略掉吗?答案是“不能”。但是,一旦把保护、恢复现场及调度的工作从操作系统交到程序员手上,那我们就可以打开脑洞,通过玩出很多新的花样来缩减这些开销。
协程,英文Coroutines,是一种基于线程之上,但又比线程更加轻量级的存在,这种由程序员自己写程序来管理的轻量级线程叫做『用户空间线程』,具有对内核来说不可见的特性。因为是自主开辟的异步任务,所以很多人也更喜欢叫它们纤程(Fiber),或者绿色线程(GreenThread)。正如一个进程可以拥有多个线程一样,一个线程也可以拥有多个协程。
协程的概念最核心的点其实就是函数或者一段程序能够被挂起(说暂停其实也没啥问题),待会儿再恢复,挂起和恢复是开发者的程序逻辑自己控制的,协程是通过主动挂起出让运行权来实现协作的。
协程的特点
- 线程的切换由操作系统负责调度,协程由用户自己进行调度,因此减少了上下文切换,提高了效率。
- 线程的默认Stack大小是1M,而协程更轻量,接近1K。因此可以在相同的内存中开启更多的协程。
- 由于在同一个线程上,因此可以避免竞争关系而使用锁。
- 适用于被阻塞的,且需要大量并发的场景。但不适用于大量计算的多线程,遇到此种情况,更好实用线程去解决。
协程的主流实现:
Go : go新建的一个 Goroutine 实际只占用 4KB 的栈空间。一个栈只占用 4KB,1GB 的内存可以创建 250 万个 Goroutine。
func main() {
number := 10
for i := 0; i < number; i++ {
go func(i int) {
// 在一个函数调用前加上go关键字,这次调用就会在一个新的goroutine中并发执行。
// 做一些业务逻辑处理
fmt.Printf("go func: %d\n", i)
}(i)
}
}
go的协程是可以无限创建的吗?也不一定。
当多个 goroutine 都需要创建同⼀个对象的时候,如果 goroutine 数过多,导致对象的创建数⽬剧增,进⽽导致 GC 压⼒增大。
形成 “并发⼤-占⽤内存⼤-GC 缓慢-处理并发能⼒降低-并发更⼤”这样的恶性循环。
在这个时候,需要有⼀个对象池,每个 goroutine 不再⾃⼰单独创建对象,⽽是从对象池中获取出⼀个对象(如果池中已经有的话)。
Java Loom项目: OpenJDK在2018年创建了Loom项目提供对用户线程(协程)的支持。Loom团队在JVMLS 2018大会上公布了他们对Jetty基于纤程改造后的测试结果,同样在5000QPS的压力下,以容量为400 的线程池的传统模式和每个请求配以一个纤程的新并发处理模式进行对比,前者的请求响应延迟在10000至20000毫秒之间,而后者的 延迟普遍在200毫秒以下。
其他基于Jvm实现的语言Scala、Kotlin也支持协程。
锁
锁是实现线程安全的重要手段,在Java里面,最基本的互斥同步手段就是synchronized关键字。
锁的劣势
Java在JDK1.5之前都是靠synchronized关键字保证同步的,这种通过使用一致的锁定协议来协调对共享状态的访问,可以确保无论哪个线程 持有守护变量的锁,都采用独占的方式来访问这些变量,如果出现多个线程同时访问锁,那第一些线线程将被挂起,当线程恢复执行时,必须等待其它线程执行完他 们的时间片以后才能被调度执行,在挂起和恢复执行过程中存在着很大的开销。锁还存在着其它一些缺点,当一个线程正在等待锁时,它不能做任何事。如果一个线 程在持有锁的情况下被延迟执行,那么所有需要这个锁的线程都无法执行下去。如果被阻塞的线程优先级高,而持有锁的线程优先级低,将会导致优先级反转 (Priority Inversion)。
CAS与重入锁
在JDK1.5中引入了底层的支持,在int、long和对象的引用等类型上都公开了 CAS的操作,并且JVM把它们编译为底层硬件提供的最有效的方法,在运行CAS的平台上,运行时把它们编译为相应的机器指令,如果处理器不支持CAS指 令,那么JVM将使用自旋锁。在原子类变量中,如java.util.concurrent.atomic中的AtomicXXX,都使用了这些底层的 JVM支持为数字类型的引用类型提供一种高效的CAS操作,而在java.util.concurrent中的大多数类在实现时都直接或间接的使用了这些 原子变量类。
自旋锁和CAS指令都是基于原子操作指令实现,
当应用程序在执行原子操作失败后,并不会释放CPU资源,
而是一直循环运行直到原子操作执行成功为止,导致CPU资源浪费。
重入锁(ReentrantLock)是Lock接口最常见的一种实现,顾名思义,它与synchronized一样是可重入 的。在基本
用法上,ReentrantLock也与synchronized很相似,只是代码写法上稍有区别而已。下面看ReentrantLock一个典型用法。unlock必须写在funally里面,保证即便发生异常,锁也能得到释放。而synchronized关键字在字节码层面已经实现了try finally安全释放锁。
Lock lock = new ReentrantLock();
try {
lock.lock();
// do somethint
} finally {
lock.unlock();
}
ReentrantLock是否就比Synchronized性能优异?那不一定。
在JEP 143(http://openjdk.java.net/jeps/143 在Java 9发布)中对竞争锁做了优化,简要内容如下:
JEP 143: Improve Contended Locking
Summary: Improve the performance of contended Java object monitors.
首先从性能上没有足够的理由优先使用ReentrantLock,其次Synchronized是在Java语法层面的同步,足够清晰,也足够简单。每个Java程序员都熟悉synchronized,但J.U.C中的Lock接口则并非如此。因此在只需要基础的同步功能时,更推荐synchronized。
锁优化
从JDK 5升级到JDK 6后,HotSpot虚拟机开发团队花费了大量的资源去实现各种锁优化技术,如适应性自旋(Adaptive Spinning)、锁消除(Lock Elimination)、锁膨胀(Lock Coarsening)、轻量级 锁(Lightweight Locking)、偏向锁(Biased Locking)等,这些技术都是为了在线程之间更高效地共享数据及解决竞争问 题,从而提高程序的执行效率。
自旋锁(spin lock)是一个典型的对临界资源的互斥手段,自旋锁是基于CAS原语的,所以它是轻量级的同步操作,它的名称来源于它的特性。自旋锁是指当一个线程尝试获取某个锁时,如果该锁已被其他线程占用,就一直循环检测锁是否被释放,而不是进入线程挂起或睡眠状态,前面我们见过线程上下文切换,线程的挂起与睡眠是很消耗资源的。由于自旋锁不进行线程状态的改变(挂起线程),所以当线程竞争不激烈时,它的响应速度极快(因为避免了线程调度的上下文切换)。自旋锁适用于锁保护的临界区很小的情况,线程竞争不激烈的场景下。如果线程之间竞争激烈或者临界区的操作特别耗时,那么线程的自旋操作就会耗费大量的cpu资源,所以这种情况下性能就会下降明显。
锁分配和膨胀
无锁设计Ringbuffer
一般来说单线程情况下,不加锁的性能 > CAS操作的性能 > 加锁的性能。在多线程环境下为了保证线程安全,往往需要加锁,例如读写锁可以保证读写互斥,读读不互斥。有没有一种数据结构能够实现无锁的线程安全呢?答案就是使用RingBuffer循环队列。在Disruptor项目中就运用到了RingBuffer。
Ringbuffer应用高性能队列:https://github.com/LMAX-Exchange/disruptor/wiki/Getting-Started
百度分布式id生成器采用Ringbuffer缓存id:https://github.com/baidu/uid-generator/blob/master/README.zh_cn.md
在RingBuffer中设置了两个指针,head和tail。head指向下一次读的位置,tail指向的是下一次写的位置。RingBuffer可用一个数组进行存储,数组内元素的内存地址是连续的,这是对CPU缓存友好的——也就是说,在硬件级别,数组中的元素是会被预加载的,因此在RingBuffer中,CPU无需时不时去主内存加载数组中的下一个元素。每个生产者或者消费者线程,会先申请可以操作的元素在数组中的位置,申请到之后,直接在该位置写入或者读取数据。通过对head和tail指针的移动,可以实现数据在数组中的环形存取。当head==tail时,说明buffer为空,当head==(tail+1)%bufferSize则说明buffer满了。
伪共享
下图是计算的基本结构。L1、L2、L3分别表示一级缓存、二级缓存、三级缓存,越靠近CPU的缓存,速度越快,容量也越小。所以L1缓存很小但很快,并且紧靠着在使用它的CPU内核;L2大一些,也慢一些,并且仍然只能被一个单独的CPU核使用;L3更大、更慢,并且被单个插槽上的所有CPU核共享;最后是主存,由全部插槽上的所有CPU核共享。当CPU执行运算的时候,它先去L1查找所需的数据、再去L2、然后是L3,如果最后这些缓存中都没有,所需的数据就要去主内存拿。走得越远,运算耗费的时间就越长。所以如果你在做一些很频繁的事,你要尽量确保数据在L1缓存中。
缓存行 (Cache Line) 便是 CPU Cache 中的最小单位,CPU Cache 由若干缓存行组成,一个缓存行的大小通常是 64 字节(这取决于 CPU),并且它有效地引用主内存中的一块地址。一个 Java 的 long 类型是 8 字节,因此在一个缓存行中可以存 8 个 long 类型的变量。Cache是由很多个cache line组成的。 CPU每次从主存中拉取数据时,会把相邻的数据也存入同一个cache line。 在访问一个long数组的时候,如果数组中的一个值被加载到缓存中,它会自动加载另外7个。因此你能非常快的遍历这个数组。
Cache Line 伪共享,就是由多个 CPU 上的多个线程同时修改自己的变量引发的。这些变量表面上是不同的变量,但是实际上却存储在同一条 Cache Line 里。它们的相互覆盖会导致频繁的缓存未命中,引发性能下降。
伪共享问题的解决方法便是字节填充。
Java 8以前实现字节填充
public final class FalseSharing
implements Runnable
{
public final static int NUM_THREADS = 4; // change
public final static long ITERATIONS = 500L * 1000L * 1000L;
private final int arrayIndex;
private static VolatileLong[] longs = new VolatileLong[NUM_THREADS];
static
{
for (int i = 0; i < longs.length; i++)
{
longs[i] = new VolatileLong();
}
}
public FalseSharing(final int arrayIndex)
{
this.arrayIndex = arrayIndex;
}
public static void main(final String[] args) throws Exception
{
final long start = System.nanoTime();
runTest();
System.out.println("duration = " + (System.nanoTime() - start));
}
private static void runTest() throws InterruptedException
{
Thread[] threads = new Thread[NUM_THREADS];
for (int i = 0; i < threads.length; i++)
{
threads[i] = new Thread(new FalseSharing(i));
}
for (Thread t : threads)
{
t.start();
}
for (Thread t : threads)
{
t.join();
}
}
public void run()
{
long i = ITERATIONS + 1;
while (0 != --i)
{
longs[arrayIndex].value = i;
}
}
public final static class VolatileLong
{
public volatile long value = 0L;
public long p1, p2, p3, p4, p5, p6; // 字节填充,消除伪共享
}
}
Java8 中实现字节填充
@sun.misc.Contended
public final static class VolatileLong
然后增加Jvm启动参数:-XX:-RestrictContended
内存池
线程有线程池,而内存也有内存池。 jemalloc就是一款内存分配器,jemalloc的优点在于减少内存碎片,为可伸缩并发提供更好的支持。
在内存分配过程中,锁会造成线程等待,对性能影响巨大。Jemalloc采用如下措施避免线程竞争锁的发生:使用线程变量,每个线程有自己的内存管理器,分配在这个线程内完成,就不需要和其它线程竞争锁。
Netty的PooledByteBuf实现了Jemalloc内存分配算法,在Netty自己实现的库里面拥有比起JVM的实现方式更少的GC。
PooledByteBufAllocator:
- Implements a jemalloc variant [1][2]
- Memory is divided into heap- and direct-arenas (see 1)
- Each arena allocates memory in chunks of some size (16 MB default as of v4.0.31)
- Chunk allocation happens lazily if needed (if no pooled buffer is available, see 2)
- ByteBuf instances are sliced off these chunks
——摘自https://cwiki.apache.org/confluence/display/FLINK/Netty+memory+allocation
PoolArena
PoolChunk
PoolChunkList
PoolSubpage
PooThreadCache