目录
并发编程的目的是为了让程序运行得更快,但并不是启动更多的线程就能让程序最大限度地并发执行。在进行并发编程时,如果希望通过多线程执行任务让程序运行得更快,这会面临非常多的挑战,比如上下文切换、死锁、以及受限于硬件和软件的资源等多种问题。
即便是单核处理器它也支持多线程执行代码,CPU是通过给每个线程分配时间片来实现的多线程执行代码机制,这个时间片约为几十毫秒(ms),人一般感知不到多个线程来回切换的动作,这让我们产生一种程序是多线程运行的错觉。
所谓上下文切换,实际上就是线程执行任务从保存到再加载的一次过程。每个线程当前时间片执行结束后会保存当前任务的状态,以便下次切回这个任务时可以方便的再加载回原状态。例如我们在翻阅英文文献时遇到了生僻的词汇,在翻译软件查找结果之前我们应当记住当前文献的阅读位置,以免找到结果后返回到文献时不清楚生僻词的位置。这样的切换会影响阅读效率,同样上下文切换也会影响多线程的执行速度。
用下面一段程序测试一下多线程和单线程对于累加变量的耗时统计
- public class ConcurrencyTest {
-
- private static final long count = 1000 * 1000 * 1000L;
-
- public static void main(String[] args) throws InterruptedException {
- concurrency();
- serial();
- }
-
- private static void concurrency() throws InterruptedException {
- long start = System.currentTimeMillis();
- Thread thread1 = new Thread(() -> {
- int a = 0;
- for (int i = 0; i < count; i++) {
- a += 1;
- }
- });
-
- int b = 0;
- for (int i = 0; i < count; i++) {
- b += 1;
- }
- thread1.join();
- long costTime = System.currentTimeMillis() - start;
- System.out.println("多线程执行时间: " + costTime);
- }
-
- private static void serial() {
- long start = System.currentTimeMillis();
- int a = 0;
- for (int i = 0; i < count; i++) {
- a += 1;
- }
-
- int b = 0;
- for (int i = 0; i < count; i++) {
- b += 1;
- }
- long costTime = System.currentTimeMillis() - start;
- System.out.println("单线程执行时间: " + costTime);
- }
-
- }
循环次数 | 串行执行耗时/ms | 并发执行耗时 | 并发与串行对比 |
10万 | 0 | 49 | 串行快 |
100万 | 10 | 50 | 串行快 |
1000万 | 15 | 50 | 串行快 |
1亿 | 80 | 90 | 差不多 |
10亿 | 889 | 500 | 并发快 |
注:每个人的电脑性能都有差异,上述只是实验数据。
通过数据可以发现当并发执行累加操作不超过亿级次时,速度会比串行执行要慢,这是因为线程有创建和上下文切换的开销。
- 无锁并发编程:如将数据的ID按照Hash算法取模分段,让不同的线程处理不同段的数据
- CAS算法:Java的Atomic包使用此算法来更新数据,不需要加锁
- 使用最少线程:避免创建大量的线程导致资源浪费
- 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换
出现死锁的四个必要条件
- 互斥:一个资源每次只能被一个进程使用;
- 请求与保持:一个进程因请求资源而阻塞时,对已获得的资源保持不放;
- 不剥夺:进程已获得的资源,在末使用完之前,不能强行剥夺;
- 循环等待:若干进程之间形成一种头尾相接的循环等待资源关系;
下面来看一段出现死锁的代码:
- public class DeadLockTest {
-
- private static final Object lockA = new Object();
- private static final Object lockB = new Object();
-
- public static void main(String[] args) throws InterruptedException {
- new DeadLockTest().deadLock();
- }
-
- private void deadLock() throws InterruptedException {
- new Thread(() -> {
- synchronized (lockA) {
- System.out.println("ThreadA get lockA");
- // 方便更容易出现死锁
- try {
- Thread.sleep(500);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- synchronized (lockB) {
- System.out.println("ThreadA get lockB");
- }
- }
- }).start();
-
- new Thread(() -> {
- synchronized (lockB) {
- System.out.println("ThreadB get lockB");
- // 方便更容易出现死锁
- try {
- Thread.sleep(500);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- synchronized (lockA) {
- System.out.println("ThreadB get lockA");
- }
- }
- }).start();
-
- }
-
- }
程序一直在运行没有停止,可以利用jps + jstack命令检测是否真的出现了死锁
在实际工作中一定要避免出现因为异常情况引发的死锁问题。
避免死锁的4个常见方法
- 避免一个线程同时获取多把锁
- 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源
- 尝试使用定时锁,使用lock.tryLock(timeout) 来替代使用内部锁机制
- 对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况
什么是资源限制?
资源限制是指在进行并发编程时,程序的执行速度受限于计算机硬件资源或软件资源。例如,服务器的带宽只有2Mb/s,某个资源的下载速度是1Mb/s,系统启动10个线程下载资源,下载速度并不会编程10Mb/s,所以在并发编程时,要考虑这些资源的限制。硬件资源限制有带宽的上传 / 下载速度,硬盘读写速度和CPU的处理速度。软件资源限制有数据库的连接数和socket连接数等。
资源限制引发的问题
如果计算机只有一个单核处理器,那么你在此基础上并发编程只会降低程序运行的效率,你期望的并发执行实际上是串行执行。其中增加了大量上下文切换和资源调度的时间。
如何解决资源限制的问题
硬件资源限制:考虑搭建服务端集群,利用“数据ID % 机器数” 得到机器编号,然后由对应编号的机器处理这个任务。
软件资源限制:尽可能的考虑使用资源池进行资源复用,常见的例如线程池、数据库连接池等都是使用这种方式。