Java并发编程的挑战

Java并发编程的挑战

并发编程的目的是为了让程序运行得更快,但是并不是开启更多线程就能让程序最大限度地并发执行,这受限于上下文切换、死锁、软硬件资源限制等各种挑战。

1.上下文切换

现代处理器都支持多线程执行代码(单核处理器也不例外,多核处理器甚至可以并行执行),其关键就在于线程共享CPU资源,通过时间片分配算法多个线程之间频繁切换执行,达到并发的目的。但是CPU每一次切换执行线程都需要保存现场(如程序计数器、堆栈指针、状态寄存器等),这些保存的内容通常被称为“上下文”,这些内容是为了在切换回原线程时能够恢复到之前的状态。因此,过于频繁的上下文切换可能反而会影响程序的执行速度,这一点需要格外注意!

补充知识:

线程控制块(Thread Control Block, TCB)并不直接包含在CPU在进行线程切换时保存的上下文中,但它是与线程管理密切相关的一个重要结构。TCB是操作系统用来存储线程相关信息的数据结构,它通常包含如下信息:

  1. 线程标识符:唯一标识线程的信息。
  2. 线程状态:如就绪、运行、阻塞等。
  3. 线程优先级:用于调度决策的优先级信息。
  4. 线程的上下文信息:这包括程序计数器、寄存器集合、堆栈指针等,这部分是CPU在上下文切换时需要保存和恢复的。
  5. 链接信息:指向其他线程控制块的链接,用于维护就绪队列、阻塞队列等。

在进行线程切换时,操作系统会保存当前线程的CPU寄存器和其他相关状态到该线程的TCB中,并从下一个要运行的线程的TCB中恢复这些信息到CPU。因此,虽然TCB本身不是CPU直接保存的内容,但它是操作系统用来存储和管理这些上下文信息的关键结构。简而言之,TCB是上下文切换过程中不可或缺的一部分,但它属于操作系统层面的管理,而不是CPU直接进行的操作。

一个证明的案例:

n个线程并发执行1个循环操作和1个线程串行执行n个循环操作的耗时对比情况,当循环次数不大时并发执行的耗时更长,当循环次数很大时并发执行的速度是串行的n倍,这是因为==线程有创建和上下文切换的开销==。

解决方案:

减少上下文切换的方法有无锁并发编程CAS算法使用最少线程使用协程

  1. 无锁并发编程:多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以使用一些办法来避免使用锁,例如将数据的ID按照Hash算法取模分段,不同的线程处理不同段的数据,减少锁竞争,这其实在ConcurrentHashMap中体现的淋漓尽致。
  2. CAS算法:JDK中的原子类都是使用的CAS算法来更新数据,不需要加锁。
  3. 使用最少线程:避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态。
  4. 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。

2.死锁

死锁也很好理解,满足下面四个条件即发生了死锁,一旦出现死锁,业务是可以感知的,因为此时不能继续提供服务了,我们只能通过dump线程查看哪个线程出现了问题。

  1. 互斥访问
  2. 持有等待
  3. 不可抢占
  4. 循环等待

解决方案:

  1. 避免一个线程同时获取多个锁。
  2. 避免一个线程在锁内同时占用多个资源,尽量保证每把锁只占用一个资源
  3. 尝试使用定时锁,使用lock.tryLock(timeout)来代替使用内部锁机制。
  4. 对于数据库锁,加锁与解锁必须在一个数据库连接里,否则会出现解锁失败的情况。

3.资源限制

资源限制是指在进行并发编程时,程序的执行速度受限于计算机硬件资源或软件资源。例如,服务器的带宽只有2Mb/s,某个资源的下载速度是1Mb/s,系统启动10个线程下载资源,下载速度不会变成10Mb/s,所以在进行并发编程时,要考虑这些资源的限制。硬件资源限制有带宽的上传/下载速度硬盘读写速度CPU的处理速度。软件资源限制有数据库的连接数socket连接数等。

在并发编程中,将代码执行速度加快的原则是将代码中串行执行的部分变成并发执行,但是如果将某段串行的代码并发执行,因为受限于资源,仍然在串行执行,这时候程序不仅不会加快执行,反而会更慢,因为增加了上下文切换和资源调度的时间

解决方案:

对于硬件资源限制,可以考虑使用集群并行执行程序。既然单机的资源有限制,那么就让程序在多机上运行。比如使用ODPS、Hadoop或者自己搭建服务器集群,不同的机器处理不同的数据。可以通过“数据ID%机器数”,计算得到一个机器编号,然后由对应编号的机器处理这笔数据。

对于软件资源限制,可以考虑使用资源池将资源复用。比如使用连接池将数据库和Socket连接复用,或者在调用对方webservice接口获取数据时,只建立一个连接。