并发编程
1、线程池的核心线程数、最大线程数该如何设置?
线程池中有两个非常重要的参数:
1、corePoolSize
:核心线程数,表示线程池中的常驻线程的个数;
2、maximumPoolSize
:最大线程数,表示线程池中能开辟的最大线程个数
那这两个参数该如何设置呢?
线程池负责执行的任务大致可以分为三种情况:
1、CPU密集型任务,比如需要大量计算的,如找出1-10000000中的素数
2、IO密集型任务,比如文件IO、网络IO、数据库查询等
3、混合型任务
CPU密集型任务
CPU密集型任务的特点是:线程在执行任务时会一直利用CPU,所以对于这种情况,就尽可能避免发生线程上下文切换。
比如现在我的电脑只有一个CPU,如果有两个线程在同时执行找素数的任务,那么这个CPU就需要额外的进行线程上下文切换,从而达到线程并行的效果,此时执行这两个任务的总时间为:
任务执行时间 * 2 + 线程上下文切换的时间
而如果只有一个线程,这个线程来执行两个任务,那么时间为:
任务执行时间 * 2
所以对于CPU密集型任务,线程数最好就等于CPU核心数,可以通过以下API拿到你电脑的核心数:
// CPU核心数
int i = Runtime.getRuntime().availableProcessors();
System.out.println(i);
只不过,为了应对线程执行过程中发生缺页中断或其他异常导致线程阻塞的请求,我们可以额外在多设置一个线程,这样当某个线程暂时不需要CPU时,可以有替补的线程来继续利用CPU
所以对于CPU密集型任务,我们可以设置线程数为:CPU核心数 + 1
IO密集型任务
线程在执行IO型密集任务时,可能大部分时间都阻塞在IO上,假如现在有10个CPU,如果我们只设置了10个线程来执行IO型任务,那么很有可能这10个线程都阻塞在了IO上,这样这10个CPU就都没活干了。所以,对于IO型任务,我们通常设置线程数为:2 * CPU核心数
不过,就算是设置为了2 * CPU核心数,也不一定是最佳的,比如,有10个CPU,线程数为20,那么也有可能这20个线程同时阻塞在了IO上,所以可以再增加线程,从而去压榨CPU的利用率。
通常,如果IO型任务执行的时间越长,那么同时阻塞在IO上的线程就可能越多,我们就可以设置更多的线程,但是,线程肯定不是越多越好,我们可以通过以下公式来进行计算:
线程数 = CPU核心数 * (1 + 线程等待时间 / 线程运行总时间)
线程等待时间:指的就是线程没有使用CPU的时间,比如阻塞在了IO
线程运行总时间:指的是线程执行完某个任务的总时间
我们可以使用工具jvisualvm抽样来估计这两个时间
实际工作中确定核心线程数的方法----压测
以上只是理论,实际工作中情况会更复杂,比如一个应用中,可能有多个线程池,除开线程池中的线程可能还有很多其他线程,比如Java本身垃圾回收的线程,或者除开这个应用还有一些其他应用也在运行,所以实际工作中如果要确定线程数,最好是压测。通过对接口的不断压测,能找到相对合适的线程数区间,当然,主要还是的看具体业务;
如果你是核心应用,你的应用经常会接收到请求,那么你的核心线程数就可以设置为你压测出来最好的结果,比如你工程设置500个线程,1000个并发下处理了5s;工程设置600个线程,1000个并发下处理了6s,这时显然500个线程更符合当前情况。最大线程数可以等于核心线程数,也可以继续细粒度压测,比如设置520个线程等等,找到略大于核心线程数,且性能相差不大的中间值作为最大核心数。
如果你是非核心应用,那么你的核心线程数可以设置小一点,最大线程数设置为你压测出来最好的结果。因为是非核心应用,请求并不会很频繁,没有必要一直运行很多线程,所以核心线程数可以设置小一点;而最大线程数则根据应用程序的最大负载和处理能力来确定(通过压测),通常设置为能够处理应用程序峰值负载的线程数,以确保在高负载情况下能够及时处理任务,以防万一。
2、如何理解Java并发中的可见性?
在Java并发编程中,可见性(Visibility)
指的是多线程并发访问共享变量时,对变量的更改能够被其他线程及时感知,即在一个线程修改变量后,其他线程能够立即看到这个变量的修改结果。
Java内存模型(Java Memory Model, JMM
)规定了Java虚拟机(JVM
)如何将主内存(Main Memory
)中的变量值同步到工作内存(Working Memory
)中,以及如何将工作内存中的变量值同步回主内存中。每个线程都有自己的工作内存,它们存储了主内存中共享变量的副本。当线程修改了一个共享变量的值,这个修改首先会反映在该线程的工作内存中,然后才会被同步到主内存中。同样地,当线程需要读取一个共享变量的值时,它会首先去自己的工作内存中查找,如果没有找到,就会从主内存中读取。
因此,如果没有正确的同步措施,就可能会出现线程A修改了一个共享变量的值,而线程B由于不知道这个修改,仍然使用旧的值的情况。这就是可见性问题。
为了解决可见性问题,Java提供了几种同步机制,如volatile
关键字、synchronized
关键字、Lock
接口以及Atomic
类型等。这些机制可以确保当一个线程修改了共享变量的值后,其他线程能够立即看到这个修改后的值。例如,volatile
关键字就可以保证被修饰的变量的可见性,因为它会禁止指令重排序,并强制将修改后的值立即写入主内存。
Java并发中的可见性是指多个线程之间对于共享变量的可见程度。为了保证线程之间正确地共享数据,我们需要使用适当的同步机制来确保可见性。
3、如何理解Java并发中的原子性?
在Java并发编程中,原子性(Atomicity) 是一个核心概念,它指的是一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。换句话说,原子性保证了某个操作在多线程环境中是一个不可分割的最小工作单位,不会被其他线程干扰,保证多线程环境下的数据一致性,避免出现数据竞争和脏数据等问题。
以下是对Java并发中原子性的深入理解:
1、不可分割性:原子操作是不可分割的,即在执行完毕之前不会被任何其它线程所中断。这确保了即使在多线程环境下,该操作也能保持其完整性。
2、无中断:一旦原子操作开始执行,就不能被其他线程中断,直到该操作完成。这避免了因线程切换或中断而导致的数据不一致问题。
3、互斥性:在任意时刻,只有一个线程可以对某个资源进行原子操作,这保证了同一时刻只有一个线程在执行这个操作。
如果没有保证原子性,我们知道,由于CPU、内存、IO(磁盘、网络)之间的性能差距,为了能充分利用CPU,当线程执行IO操作时,线程会让出CPU,使得CPU去执行其他线程的指令,并且本身来说,为了达到线程并发执行的效果,CPU也会按固定时间片来切换执行不同线程。
当我们执行i++这行代码时,底层对应的是三条指令:
1、从内存中读取i的值
2、对i+1
3、写回i的值到CPU高速缓存
但是有可能线程A执行了第一条指令后,就发生了线程切换,线程A相当于暂停执行,此时如果有另外一个线程B也在执行i++,并且把3条指令都执行完了,那么线程B得到的结果是i = 2,然后线程A又切换回来继续执行,最终导致线程A得到的i也为2,正常来说i应该等于3的,这就是没有保证原子性引发的问题。
在Java中,可以使用锁机制来保证操作的原子性
1、使用synchronized关键字:synchronized
关键字可以确保一个代码块或方法在同一时刻只能被一个线程访问,从而实现原子性。但需要注意的是,synchronized
是重量级的同步机制,可能会带来较大的性能开销。
2、使用Lock接口及其实现类(如ReentrantLock、ReadWriteLock等):Lock接口
提供了比synchronized
更灵活的锁机制,可以实现可重入锁、读写锁等复杂场景。通过Lock
的lock()
和unlock()
方法,可以手动控制锁的获取和释放,从而实现原子性。
3、使用volatile关键字:volatile
关键字可以确保变量的可见性,但它并不能保证复合操作的原子性。在某些情况下,volatile
可以配合CAS(Compare-And-Swap)
操作来实现原子性。
使用java.util.concurrent.atomic
包中的原子类:Java提供了一组原子类(如AtomicInteger
、AtomicLong
、AtomicBoolean
等),这些类提供了基于CAS操作的原子性保证。这些类通常用于实现计数器、标志位等简单的并发需求。
public class AtomicIntegerExample {
// AtomicBoolean是提供原子布尔操作的类
// AtomicLong与AtomicInteger类似,但它提供的是对长整型(long)的原子操作
// AtomicInteger是提供原子整数操作的类
private static AtomicInteger counter = new AtomicInteger(0);
public static void increment() {
counter.incrementAndGet(); // 原子地增加计数器的值,并返回更新后的值
}
public static int getCount() {
return counter.get(); // 获取当前计数器的值
}
public static void main(String[] args) throws InterruptedException {
// 模拟多线程环境增加计数器
for (int i = 0; i < 1000; i++) {
new Thread(() -> increment()).start();
}
// 等待一段时间让线程执行
Thread.sleep(1000);
// 输出计数器的值
System.out.println("Final Counter Value: " + getCount());
}
}
这些原子类提供了线程安全的操作,但在复杂的并发场景中,它们可能不足以满足需求。在这些情况下,你可能需要使用更复杂的同步机制,如锁(Lock
接口)或信号量(Semaphore
)。此外,还需要注意CAS
操作在高并发下可能导致的 ABA(Abort-Before-Abort)
和自旋等待(busy-waiting
) 等问题
总的来说,理解Java并发中的原子性对于编写高效、安全的并发程序至关重要。在实际应用中,应根据具体场景选择合适的同步机制来实现原子性。
4、如何理解Java并发中的有序性?
在Java并发编程中,有序性(Ordering)是指程序中的操作按照代码中的顺序执行,特别是在多线程环境下,这涉及到线程间操作和单个线程内操作的执行顺序。然而,由于Java内存模型(Java Memory Model, JMM
)和处理器优化等因素,发生了指令重排,实际的执行顺序可能与代码中的顺序不一致,这可能导致并发问题。
具体来说,有序性问题主要体现在以下几个方面:
1、重排序(Reordering):为了提高程序的性能,编译器和处理器会对指令进行重排序。这种重排序是单线程内的,即在单个线程中,虽然指令的执行顺序被改变了,但单线程程序执行的结果仍然是正确的。然而,在多线程环境中,这种重排序可能导致一个线程看到另一个线程Visibility
的中间状态,从而产生不可预知的结果。
2、内存可见性(Memory):在并发编程中,一个线程对共享变量的修改,如果没有正确的同步机制,可能不会被其他线程立即看到。这是因为每个线程都有自己的工作内存(本地缓存),线程对变量的所有操作(读取、赋值等)都必须在工作内存中完成,而不能直接读写主内存中的变量。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
为了解决有序性和内存可见性问题,Java内存模型定义了happens-before
关系。happens-before
是Java内存模型中的偏序关系,它定义了哪些操作必须在其他操作之前完成。如果一个操作A happens-before 另一个操作B,那么操作A的结果对操作B是可见的。
在Java中,有几种方式可以保证有序性和内存可见性:
1、volatile关键字:volatile
关键字可以确保变量的可见性和禁止指令进行重排序优化。当一个变量被声明为volatile
时,它会告诉JVM
这个变量是共享且不稳定的,每次使用它都会从主内存中读取最新的值。同时,volatile还可以保证写操作的顺序性,即在一个volatile写操作之前的所有读写操作都会先于这个volatile写操作完成。
2、synchronized关键字:synchronized
关键字不仅可以实现互斥访问,还可以保证同一时刻只有一个线程可以执行被synchronized
修饰的代码块或方法。在synchronized
块内,指令的执行顺序是严格按照代码的编写顺序进行的。此外,synchronized
还隐含了两个重要的有序性保证:每个时刻,只有一个线程可以执行同一个对象实例的synchronized(this)
方法块;同一个锁对象,只有一个线程可以获得锁,当释放锁后,另一个线程才能获得锁。
3、Lock接口及其实现类:Lock
接口提供了比synchronized
更灵活的锁机制,可以实现可重入锁、读写锁等复杂场景。通过使用Lock接口及其实现类(如ReentrantLock
、ReadWriteLock
等),可以更加精细地控制线程的执行顺序和可见性。
总之,理解Java并发中的有序性对于编写正确、高效的并发程序至关重要。在编写并发代码时,需要注意指令重排序和内存可见性可能带来的问题,并采取适当的同步机制来保证程序的有序性和内存可见性。