并发编程的目的是为了让程序运行的更快,但是并不是启动更多的线程就能提高程序的运行速度。并发编程之所以会提高程序的运行速度,在我看来有这几方面,第一个是通过并发编程会充分利用多核 CPU 的优势,即若没有使用并发编程可能并没有完全利用 CPU 的性能;第二个是若程序因为 I/O 或其他任何临界资源的竞争而使程序不能运行下去时可以通过切换线程去做硬件和软件资源已经 ready 的任务;由于以上两点并发编程一般情况下是可以提高程序的运行速度的。但是由于上下文切换问题、死锁问题、以及硬件和软件资源的限制问题等都会对并发编程带来很多挑战。
上下文切换
即使是单核处理器也支持多线程执行代码, CPU 通过给每个线程分配 CPU 时间片来实现这个机制。时间片是 CPU 分配给各个线程的执行时间,由于每个时间片非常短,所以 CPU 通过不停的切换线程执行,让每个线程都以为自己独占了 CPU。CPU 通过时间片分配算法来循环执行任务,当任务切换之前会保存当前任务的状态,以便下次时间片到来时恢复当前任务,任务从保存再加载的这个过程就是一次上下文切换。也就是说上下文切换其实是有代价的,随着线程的增多上下文切换的代价当然也约大。所以说并不是线程越多程序一定就执行越快。
因此为了减少上下文切换的影响应该尽量去减少上下文切换,减少上下文切换的方法有无锁并发编程、 CAS 算法、使用最少线程和使用协程
- 无锁并发编程。多线程竞争锁时会引起上下文切换,所以多线程处理数据时可以用一些办法来避免使用锁,如将数据按 ID Hash 算法取模分段,不同的线程处理不同段的数据,其中 ConcurrentHashMap 就是采用了这种方式,后面有机会我会单独写一篇文章介绍 ConcurrentHashMap 的原理。
- CAS 算法。Java 的 Atomic 包使用 CAS 算法来更新数据,而不需要加锁,后面我会单独写文章介绍 CAS 算法是怎么回事。
- 使用最少线程。避免创建不必要的线程,这可能是最容易想到的方式了。
- 协程。在单线程里面实现多任务的调度,并在单线程里维持多个任务的切换。
死锁
锁是个非常有用的线程同步工具,运用场景多、使用简单、易于理解等。但同时它也会带来一些问题,比如死锁。一旦产生死锁就会造成系统功能的不可用。
下面我们简单了解一下死锁发生的四个条件。
- 互斥条件。及某个资源只能同时被一个线程或进程持有,这种资源也叫临界资源。
- 请求保持条件。及一个线程持有了某个临界资源后还要请求另一个临界资源,若另一个资源没有请求到也不会释放当前持有的资源。
- 不可剥夺条件。即一个线程持有了某个临界资源之后不能强制使其放弃当前持有的资源。
- 循环等待。系统中若干进程组成环路,该环路中每个进程都在等待相邻进程正占用的资源。
只有以上四个条件同时满足时才会发生死锁。下面我们介绍避免死锁的几个常用方法。
- 避免一个线程同时获取多个锁
- 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源
- 尝试使用定时锁
- 对于数据库锁,加锁和解锁必须在一个数据库连接里
资源限制的条件
资源限制是指在进行并发编程时,程序的执行速度受限于计算机硬件或软件资源。例如,服务器的带宽只有 2Mb/s,某个资源的额下载速度是 1Mb/s ,系统启动 10 个线程下载资源,下载速度也最多只有 2Mb/s ,所以在进行并发编程时要考虑到资源的限制。硬件资源限制有带宽的上传/下载速度、硬盘读写速度和 CPU 的处理速度。软件资源限制有数据库的连接数和 socket 连接数等。
本文简单介绍了并发编程中可能遇到的挑战,并给出了一些解决建议。并发程序的问题相比与单线程程序更加难以定位。所以对于 Java 工程师而言在并发编程时少造轮子尽可能的使用 JDK 并发包中提供的各种并发工具去解决并发问题。