编写线程安全代码的核心是管理对状态的访问,尤其是对共享、可变状态的访问。
Writing thread-safe code is, at its core, about managing access to state, and in particular to shared, mutable state.
一、ExecutorService使用中execute()和submit()
实践发现:
- execute执行方式抛出异常显示在控制台了
- submit执行方式啥都没有输出。
线程池在处理任务时通常不会直接抛出异常,因为它主要用于调度和执行任务,而不是验证参数的有效性。
对于 execute()
方法,如果任务在执行过程中抛出异常并且没有被显式捕获,线程池会将异常记录到内部的 UncaughtExceptionHandler
,并在控制台上输出异常的堆栈跟踪信息。
对于 submit()
方法,由于它返回一个 Future
对象,任何从任务中抛出的异常都会被包装在 Future
对象中。如果你不主动调用 Future.get()
方法获取结果,异常将不会被抛出和显示。如果你调用了 Future.get()
并且任务抛出了异常,那么 get()
方法将会抛出 ExecutionException
,其中包含真实的异常信息。这意味着你可以通过 Future.get()
方法来检查任务是否成功完成,并获取异常信息(如果有)。
两者优缺点
execute()
和 submit()
方法都可以用于向线程池提交任务,但它们在使用场景、优势和劣势方面有一些区别。
execute()
方法主要用于提交实现了 Runnable
接口的任务,而 submit()
方法可以提交实现了 Runnable
或 Callable
接口的任务。
下面是两者的优势和劣势:
- **`execute()` 的优势:**
1. 简洁:`execute()` 方法比较简洁,不需要返回结果,适合于不关心任务的返回结果,只需简单执行任务的场景。
2. 异常处理:`execute()` 方法会直接将任务内部抛出的异常显示在控制台上,方便调试和排查问题。
- **`execute()` 的劣势:**
1. 无法获取任务执行的结果:`execute()` 方法没有返回值,无法获取任务的执行结果。如果需要获取任务结果或者判断任务是否成功完成,就无法使用 `execute()` 方法。
2. 任务的异常处理比较困难:由于没有返回值,我们需要在任务内部进行异常处理,并且需要对异常进行特殊处理,例如将异常记录到日志中。
- **`submit()` 的优势:**
1. 可以获取任务执行结果:`submit()` 方法返回一个 `Future` 对象,可以通过 `Future` 对象获取任务执行的结果。这对于需要获取任务结果、进行后续处理或者判断任务是否成功完成的场景非常有用。
2. 异常处理方便:通过调用 `Future.get()` 方法,可以捕获任务内部抛出的异常,并对异常进行处理。
3. 支持提交 `Callable` 任务:`submit()` 方法除了支持提交 `Runnable` 任务外,还可以提交实现了 `Callable` 接口的任务,这样可以获取任务执行的结果。
- **`submit()` 的劣势:**
1. 稍微复杂:相比于 `execute()` 方法,`submit()` 方法稍微复杂一些,需要处理 `Future` 对象的返回值和异常。
综上所述,如果你只是简单地希望执行任务而不关心任务的返回结果,或者你可以在任务内部处理异常,并且方便地在控制台上查看异常信息,那么使用 execute()
方法是一个不错的选择。但是,如果你需要获取任务执行的结果、进行后续处理,并且更灵活地处理任务内部的异常,那么使用 submit()
方法会更合适。
①关闭与阻塞
@Override
public void close() throws Exception {
shutdown();//停止线程池接受新的任务,并尝试将已经提交的但还未开始执行的任务取消。
awaitTermination();//阻塞当前线程直到线程池中的所有任务执行完成或超时。
}
通过在 close()
方法中先调用 shutdown()
再调用 awaitTermination()
,可以确保在关闭线程池之前等待任务执行完成。这样,线程池中的任务就有足够的时间来完成执行,避免了任务被提前取消的情况发生。
二、锁修饰范围、锁住什么(锁的粒度)、互斥范围、优化锁的使用
①synchronized(this)、synchronized(class)与synchronized(Object)的区别
`synchronized` 关键字用于实现线程的同步,确保多个线程在访问共享资源时按照一定的顺序进行执行。在 Java 中,你可以使用 `synchronized` 关键字来实现对对象、类或代码块的同步。
下面是 `synchronized(this)`、`synchronized(class)` 和 `synchronized(Object)` 的区别:
1. `synchronized(this)`
`synchronized(this)` 用于同步一个对象的实例方法或实例代码块。它锁住的是当前对象的实例。当一个线程获得了这个锁之后,其他试图访问该对象实例中被 `synchronized(this)` 保护的代码块或方法的线程将会被阻塞,直到获得锁的线程释放锁。
2. `synchronized(class)`
`synchronized(class)` 用于同步一个类的静态方法或静态代码块。它锁住的是整个类的实例,而不是特定的对象实例。因此,当一个线程获得了 `synchronized(class)` 锁之后,其他试图访问该类中被 `synchronized(class)` 保护的静态代码块或方法的线程也将被阻塞,直到获得锁的线程释放锁。
需要注意的是,`synchronized(class)` 会影响整个类的所有实例,而不管是哪个实例调用了 `synchronized(class)` 所保护的方法或代码块,因此需要谨慎使用。
3. `synchronized(Object)`
`synchronized(Object)` 用于同步一个特定的对象实例。可以使用任意对象作为锁对象,比如一个专门用于同步的对象(通常称为监视器对象)。
当一个线程获得了 `synchronized(Object)` 锁之后,其他试图访问使用相同锁对象的 `synchronized(Object)` 保护的代码块或方法的线程将会被阻塞,直到获得锁的线程释放锁。不同的线程可以使用不同的锁对象进行同步,这样它们之间的同步操作不会互相干扰。
需要注意的是,如果多个线程使用了不同的锁对象,那么它们之间的同步将失效,因为它们没有竞争同一个锁。
综上所述,`synchronized(this)` 和 `synchronized(Object)` 都是针对特定对象实例进行同步的,而 `synchronized(class)` 是锁住整个类的所有实例。选择使用哪种方式取决于你的需求和设计。
②锁修饰对象包括:
synchronized
关键字可以修饰以下几个对象:
- 实例方法:使用
synchronized
修饰实例方法时,该方法在同一时间只能被一个线程访问。当一个线程正在执行该实例方法时,其他线程需要等待。
public synchronized void instanceMethod() {
// 方法体
}
- 静态方法:使用
synchronized
修饰静态方法时,该方法在同一时间只能被一个线程访问。和实例方法一样,当一个线程正在执行该静态方法时,其他线程需要等待。
public static synchronized void staticMethod() {
// 方法体
}
- 代码块:使用
synchronized
修饰代码块时,可以指定加锁的对象。多个线程在同一时间只能有一个线程执行被synchronized
修饰的代码块。通过指定不同的对象,可以细粒度地控制并发访问。
Object lock = new Object();
synchronized (lock) {
// 代码块
}
- 类对象(Class对象):使用
synchronized
修饰类对象时,该类的所有实例方法和代码块都会受到影响。同一时间只能有一个线程访问该类的任意实例方法或代码块。
public class MyClass {
public void instanceMethod() {
synchronized (MyClass.class) {
// 代码块
}
}
}
需要注意的是,当多个线程访问共享资源时,使用 synchronized
可以确保线程安全性,避免数据竞争和并发问题。但过度使用 synchronized
可能会导致性能下降,因此在设计并发访问时应根据需求谨慎使用该关键字。
③互斥范围
互斥范围指的是在多线程环境下,保证共享资源访问的互斥性的范围。通过使用锁机制来控制互斥范围,确保同一时间只有一个线程可以访问共享资源,从而避免出现竞态条件和数据不一致的问题。
具体来说,互斥范围取决于使用锁的方式和锁的粒度:
1. 锁的方式:
- 悲观锁:使用关键字 `synchronized` 或 `Lock` 接口的实现类来对关键代码块或方法进行加锁,从而将整个关键代码块或方法作为互斥范围。
- 乐观锁:通过无锁算法或基于 CAS(Compare and Swap)操作的乐观并发控制方法,将互斥范围缩小到共享资源的具体操作。
2. 锁的粒度:
- 细粒度锁:通过细致划分共享资源,使用更小的锁粒度,使得同时访问不同共享资源的线程之间不会相互阻塞。
- 粗粒度锁:使用较大的锁粒度,将多个相关的共享资源放在同一锁范围内,虽然减少了锁的开销,但可能导致线程之间的竞争和阻塞增加。
在选择互斥范围时,需要综合考虑以下因素:
- 线程安全性需求:确定哪些共享资源需要保证线程安全,在需要的地方加锁。
- 性能要求:尽量减小互斥范围,避免不必要的锁竞争和阻塞,提升程序的并发性能。
- 锁的粒度:根据具体场景和共享资源关系,选择合适的锁粒度,平衡线程同步和性能开销。
需要特别注意的是,锁的粒度过小可能导致频繁的锁竞争和上下文切换,而锁的粒度过大可能限制了并发性能。因此,在设计并发程序时,需要仔细评估并选择适当的互斥范围和锁粒度。
悲观锁(写多读少):
悲观锁是一种并发控制的机制,它基于悲观的假设:在多线程环境下,总是假设会发生并发冲突,并通过加锁来保证共享资源的互斥访问。
悲观锁的实现通常基于以下两个主要的机制:
1. 基于互斥锁:
- 使用关键字 `synchronized` 或 `Lock` 接口的实现类,将关键代码块或方法进行加锁。
- 当某个线程获得锁后,其他线程需要等待该线程释放锁才能继续执行,从而保证了共享资源的互斥访问。
2. 基于数据库锁:
- 在关系型数据库中,悲观锁可以通过使用 SELECT ... FOR UPDATE 或 SELECT ... LOCK IN SHARE MODE 语句来实现。
- SELECT ... FOR UPDATE 语句会对查询结果的行加排他锁,阻塞其他事务的写操作。
- SELECT ... LOCK IN SHARE MODE 语句会对查询结果的行加共享锁,阻塞其他事务的写操作,但允许其他事务读取数据。
悲观锁的优点在于它保证了数据的一致性和安全性,通过加锁来避免并发冲突。但也存在以下一些注意事项:
1. 锁的粒度:悲观锁的粒度通常较大,因为一旦获得了锁,其他线程就需要等待。如果锁的粒度过大,可能会导致线程间的竞争和阻塞增加,影响并发性能。
2. 死锁风险:悲观锁如果使用不当,可能会产生死锁问题。当多个线程相互等待对方释放锁时,可能会导致死锁发生,造成程序无法继续执行。
在实际应用中,悲观锁常用于写多读少的场景,例如数据库中的悲观并发控制、文件操作等。它保证了数据的安全性和一致性,但也引入了线程间的竞争和阻塞。开发人员需要合理设计锁的粒度和避免死锁问题,以提高并发性能和避免系统崩溃。
*死锁:
死锁是多线程编程中常见的问题,当两个或多个线程互相持有对方所需的锁资源,并且彼此等待对方释放锁时,就会发生死锁。Java中的死锁问题可以通过以下方式导致:
1. 互斥条件:线程同时申请多个锁资源,并且这些锁资源是互斥的(只能由一个线程持有)。
2. 请求和保持条件:线程在持有锁资源的同时继续请求其他锁资源。
3. 不可剥夺条件:已经获得的锁资源不能被其他线程强制性剥夺。
4. 循环等待条件:多个线程形成循环等待锁资源的关系。
为了有效地避免死锁问题,可以采取以下几种策略:
1. 避免策略:
- 分析和设计系统,尽量避免出现死锁发生的情况,避免存在循环等待条件。
- 尽量按照固定顺序获取锁资源,避免不同线程获取锁的顺序不一致导致死锁。
- 避免长时间持有锁资源,及时释放不再需要的锁。
2. 检测与恢复策略:
- 可以使用工具或算法来检测死锁的发生,例如死锁检测算法中的银行家算法。
- 一旦检测到死锁,可以采取一些措施来恢复系统,如强制释放一部分或所有的锁资源,终止某些线程等。
3. 鸵鸟策略:
- 对于某些情况下死锁发生的概率较低,或者代价过高的死锁恢复策略,可以选择忽略死锁问题,但要确保死锁不会造成系统崩溃。
4. 预防策略:
- 在设计和开发过程中,采用良好的编码规范,避免潜在的死锁问题。
- 尽量减少锁的使用,采用无锁算法或使用并发容器等替代方案。
- 注意锁的粒度控制,尽量保持锁的范围小,减少死锁风险。
总之,避免死锁问题需要综合考虑系统设计、并发控制策略和编码规范等方面。通过合理的锁使用、资源申请顺序的规划和及时释放锁资源等方法,可以有效预防和解决死锁问题。
乐观锁(读多写少):
乐观锁是一种并发控制的机制,它通过假设在绝大多数情况下不会出现冲突来提高并发性能。与悲观锁相比,乐观锁不需要加锁,而是在更新共享资源之前进行验证。如果发现其他线程已经修改了共享资源,就会回滚当前操作或重试。
乐观锁的实现通常基于以下两个主要的机制:
1. 版本号机制:
- 在共享资源中引入一个版本号字段,通常是一个整数或时间戳。
- 当读取共享资源时,同时将版本号记录下来。
- 在更新共享资源时,检查版本号是否被其他线程修改过。如果没有修改,则继续执行更新操作;否则,根据相应策略进行回滚或重试。
2. CAS(Compare and Swap)操作:
- 使用原子操作 CAS 来实现乐观锁。
- CAS 包含三个操作数:内存地址 V、期望值 A 和新值 B。
- 当前线程将期望值 A 和内存地址 V 中的实际值进行比较,如果相等,则将内存地址 V 中的值替换为新值 B;否则,不做任何操作。
- CAS 是一种无锁算法,可以保证原子性,因此用于实现乐观锁时非常方便。
乐观锁的优点在于它避免了线程之间的阻塞和等待,提高了并发性能。不过,乐观锁也有一些注意事项:
1. 冲突检测:在更新共享资源之前,需要检测其他线程是否已经修改了该资源。如果发现冲突,可能需要进行回滚或重试操作。
2. 数据一致性:乐观锁只是假设并发冲突很少发生,因此不会对共享资源进行加锁保护。这可能导致数据一致性问题,需要开发人员自行处理。
在实际应用中,乐观锁常用于读多写少的场景,例如数据库中的乐观并发控制、缓存更新等。它可以提高并发性能,减少锁竞争,但需要开发人员合理设计并处理冲突和一致性问题。