线程池(四):并发编程常见问题解析
线程池(四):并发编程常见问题解析
- 线程池(四)并发编程常见问题解析
- 一、synchronized和Lock的区别
- 语法层面
- 锁的获取与释放
- 锁的特性
- 功能扩展
- 二、死锁产生的条件
- 互斥条件
- 占有且等待条件
- 不可剥夺条件
- 循环等待条件
- 三、如何进行死锁诊断
- 查看线程堆栈信息
- 使用Java VisualVM
- 代码层面的日志和监控
- 四、ConcurrentHashMap
- JDK1.7中ConcurrentHashMap
- JDK1.8中ConcurrentHashMap
- 五、导致并发程序出现问题的根本原因
- 原子性
- 内存可见性
- 有序性
线程池(四)并发编程常见问题解析
一、synchronized和Lock的区别
语法层面
- synchronized:是Java中的关键字,通过在方法声明或代码块上使用来实现同步功能,语法相对简洁,例如:
public synchronized void method() {// 同步代码块
}
或者
Object lock = new Object();
public void anotherMethod() {synchronized (lock) {// 同步代码块}
}
- Lock:是Java.util.concurrent.locks包下的接口,需要通过实现类(如ReentrantLock)来使用,使用时需要显式地调用加锁和解锁方法,语法上相对繁琐一些,示例如下:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;public class LockExample {private Lock lock = new ReentrantLock();public void method() {lock.lock();try {// 同步代码块} finally {lock.unlock();}}
}
锁的获取与释放
- synchronized:当线程进入synchronized修饰的代码块或方法时,会自动获取锁;当代码执行完毕(正常执行结束或者抛出异常),会自动释放锁,无需手动干预。
- Lock:需要手动调用
lock()
方法获取锁,如果没有正确调用unlock()
方法释放锁,可能会导致死锁等问题。通常建议在finally
块中调用unlock()
方法,以确保即使发生异常也能正确释放锁。
锁的特性
- synchronized:是可重入锁,即同一个线程在持有锁的情况下,可以再次进入该锁同步的代码块或方法。它是一种非公平锁,默认情况下,多个线程竞争锁时,无法保证等待时间最长的线程优先获取锁。
- Lock:以ReentrantLock为例,它也是可重入锁。同时,ReentrantLock可以通过构造函数指定是否为公平锁,如
new ReentrantLock(true)
创建的就是公平锁,公平锁会尽量保证等待时间最长的线程优先获取锁。
功能扩展
- synchronized:功能相对单一,主要用于实现基本的同步功能。
- Lock:提供了更多的功能,例如可以使用
tryLock()
方法尝试获取锁,在指定时间内获取不到锁时可以返回,避免线程无限期等待;还可以通过Condition
接口实现更灵活的线程间通信,比synchronized
配合wait()
、notify()
方法更加灵活。
二、死锁产生的条件
互斥条件
资源不能被共享,只能由一个进程或线程占用。例如,在多线程访问数据库连接时,一个数据库连接在同一时刻只能被一个线程使用,不能同时被多个线程共享使用。
占有且等待条件
一个进程或线程已经持有了至少一个资源,但又在等待其他资源。比如线程A已经持有了资源R1,同时又在等待获取资源R2才能继续执行。
不可剥夺条件
已经分配的资源不能从相应的进程或线程中被强制剥夺。即一个线程获取到的资源,在它主动释放之前,其他线程不能强行拿走。例如,线程B获取到了文件读写锁,在它完成操作释放锁之前,其他线程无法强制剥夺该锁。
循环等待条件
存在一个进程或线程资源的循环链,链中的每个进程或线程都在等待下一个进程或线程所占用的资源。比如线程A等待线程B占用的资源,线程B又等待线程C占用的资源,而线程C等待线程A占用的资源,形成一个循环等待的局面。
三、如何进行死锁诊断
查看线程堆栈信息
在Java中,可以通过jstack
命令来查看线程的堆栈信息。当怀疑程序发生死锁时,首先使用jps
命令找到Java进程的PID(进程ID),然后使用jstack <PID>
命令查看该进程下所有线程的堆栈信息。如果存在死锁,jstack
输出的信息中会有明确的提示,并且会列出发生死锁的线程以及它们各自等待的资源情况。
使用Java VisualVM
Java VisualVM是一款功能强大的可视化性能分析工具。可以通过它连接到运行中的Java程序,在“线程”标签页中查看线程的状态。如果发生死锁,它会自动检测并在界面上显示出死锁的相关信息,包括死锁的线程、线程等待的资源等,方便开发者定位和分析问题。
代码层面的日志和监控
在编写多线程程序时,可以在关键的加锁、解锁以及资源获取等位置添加详细的日志记录,记录线程的操作和资源占用情况。通过分析日志,可以了解线程的执行流程和资源竞争情况,从而发现潜在的死锁问题。同时,也可以实现一些简单的监控机制,定期检查线程的状态和资源占用情况,当发现异常的资源等待或循环等待情况时,及时报警提示可能存在死锁风险。
四、ConcurrentHashMap
JDK1.7中ConcurrentHashMap
- 数据结构:JDK 1.7中的ConcurrentHashMap采用了“分段锁”的设计思想。它内部由一个Segment数组组成,每个Segment相当于一个小的哈希表,Segment继承自ReentrantLock,起到锁的作用。每个Segment保护若干个HashEntry数组,HashEntry是存储实际数据的节点。
- 原理:当进行写操作(如put操作)时,首先根据key的哈希值计算出对应的Segment,然后对该Segment加锁,再进行数据插入或更新操作。读操作(如get操作)时,由于HashEntry的value和next引用都被声明为volatile,所以无需加锁就可以保证读取到最新的值。这种分段锁的设计允许多个线程同时对不同的Segment进行写操作,提高了并发性能。
JDK1.8中ConcurrentHashMap
- 数据结构:JDK 1.8对ConcurrentHashMap进行了改进,不再使用分段锁,而是采用了数组 + 链表 + 红黑树的结构。当链表长度达到一定阈值(默认8)时,会将链表转换为红黑树,以提高查找效率。
- 原理:在进行写操作时,首先根据key的哈希值计算出对应的数组下标,如果该位置没有元素,就直接通过CAS操作插入元素;如果该位置是链表结构,就对链表头节点加锁进行插入或更新操作;如果是红黑树结构,就对红黑树的根节点加锁进行操作。读操作基本无锁,通过volatile保证数据的可见性。相比JDK 1.7,JDK 1.8的ConcurrentHashMap在减少锁的粒度、提高并发性能和空间利用率等方面都有进一步的优化。
五、导致并发程序出现问题的根本原因
原子性
- 概念:原子性是指一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。在并发环境下,多个线程可能会同时访问和修改共享变量,如果对共享变量的操作不是原子性的,就可能导致数据不一致的问题。例如,对于
i++
这样的操作,在底层实际上是由读取、加1、写入三个步骤组成,不是原子操作。当多个线程同时执行i++
时,就可能出现不同线程读取到相同的i
值,然后都进行加1操作,最终导致结果错误。 - 解决方式:可以使用synchronized关键字或者Lock接口来对共享变量的操作进行同步,保证在同一时刻只有一个线程能够对共享变量进行操作;也可以使用Java并发包中的原子类(如AtomicInteger),这些原子类通过CAS等机制保证了对变量操作的原子性。
内存可见性
- 概念:在Java内存模型中,每个线程都有自己的工作内存,线程对共享变量的操作是在自己的工作内存中进行的。当一个线程修改了共享变量的值后,不会立即将其写回到主内存中,其他线程读取这个共享变量时,可能读取到的还是旧值,这就导致了内存可见性问题。
- 解决方式:使用volatile关键字修饰共享变量,volatile可以保证被修饰的变量在被一个线程修改后,能够立即被其他线程看到最新的值;也可以通过加锁(如synchronized、Lock)的方式,在释放锁时将工作内存中的变量值写回到主内存,获取锁时从主内存中读取最新值,从而保证内存可见性。
有序性
- 概念:为了提高性能,编译器和处理器可能会对指令进行重新排序。在单线程环境下,指令重排序不会影响程序的最终结果,但在多线程环境下,可能会导致程序出现错误的结果。例如,在一个线程中对共享变量的初始化操作和赋值操作如果被重排序,可能会导致其他线程读取到未初始化的值。
- 解决方式:使用volatile关键字可以禁止指令重排序;也可以通过加锁(如synchronized、Lock)的方式,因为锁的获取和释放操作会形成一个内存屏障,保证在锁内的操作按照顺序执行,从而保证有序性。