编写线程安全代码的核心是管理对状态的访问,尤其是对共享、可变状态的访问

编写线程安全代码的核心是管理对状态的访问,尤其是对共享、可变状态的访问。

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() 方法可以提交实现了 RunnableCallable 接口的任务。

下面是两者的优势和劣势:

- **`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 关键字可以修饰以下几个对象:

  1. 实例方法:使用 synchronized 修饰实例方法时,该方法在同一时间只能被一个线程访问。当一个线程正在执行该实例方法时,其他线程需要等待。
public synchronized void instanceMethod() {
    
    
    // 方法体
}
  1. 静态方法:使用 synchronized 修饰静态方法时,该方法在同一时间只能被一个线程访问。和实例方法一样,当一个线程正在执行该静态方法时,其他线程需要等待。
public static synchronized void staticMethod() {
    
    
    // 方法体
}
  1. 代码块:使用 synchronized 修饰代码块时,可以指定加锁的对象。多个线程在同一时间只能有一个线程执行被 synchronized 修饰的代码块。通过指定不同的对象,可以细粒度地控制并发访问。
Object lock = new Object();
synchronized (lock) {
    
    
    // 代码块
}
  1. 类对象(Class对象):使用 synchronized 修饰类对象时,该类的所有实例方法和代码块都会受到影响。同一时间只能有一个线程访问该类的任意实例方法或代码块。
public class MyClass {
    
    
    public void instanceMethod() {
    
    
        synchronized (MyClass.class) {
    
    
            // 代码块
        }
    }
}

需要注意的是,当多个线程访问共享资源时,使用 synchronized 可以确保线程安全性,避免数据竞争和并发问题。但过度使用 synchronized 可能会导致性能下降,因此在设计并发访问时应根据需求谨慎使用该关键字。

③互斥范围

互斥范围指的是在多线程环境下,保证共享资源访问的互斥性的范围。通过使用锁机制来控制互斥范围,确保同一时间只有一个线程可以访问共享资源,从而避免出现竞态条件和数据不一致的问题。

具体来说,互斥范围取决于使用锁的方式和锁的粒度:

1. 锁的方式:
   - 悲观锁:使用关键字 `synchronized` 或 `Lock` 接口的实现类来对关键代码块或方法进行加锁,从而将整个关键代码块或方法作为互斥范围。
   - 乐观锁:通过无锁算法或基于 CASCompare and Swap)操作的乐观并发控制方法,将互斥范围缩小到共享资源的具体操作。

2. 锁的粒度:
   - 细粒度锁:通过细致划分共享资源,使用更小的锁粒度,使得同时访问不同共享资源的线程之间不会相互阻塞。
   - 粗粒度锁:使用较大的锁粒度,将多个相关的共享资源放在同一锁范围内,虽然减少了锁的开销,但可能导致线程之间的竞争和阻塞增加。

在选择互斥范围时,需要综合考虑以下因素:
- 线程安全性需求:确定哪些共享资源需要保证线程安全,在需要的地方加锁。
- 性能要求:尽量减小互斥范围,避免不必要的锁竞争和阻塞,提升程序的并发性能。
- 锁的粒度:根据具体场景和共享资源关系,选择合适的锁粒度,平衡线程同步和性能开销。

需要特别注意的是,锁的粒度过小可能导致频繁的锁竞争和上下文切换,而锁的粒度过大可能限制了并发性能。因此,在设计并发程序时,需要仔细评估并选择适当的互斥范围和锁粒度。

悲观锁(写多读少):

悲观锁是一种并发控制的机制,它基于悲观的假设:在多线程环境下,总是假设会发生并发冲突,并通过加锁来保证共享资源的互斥访问。

悲观锁的实现通常基于以下两个主要的机制:

1. 基于互斥锁:
   - 使用关键字 `synchronized``Lock` 接口的实现类,将关键代码块或方法进行加锁。
   - 当某个线程获得锁后,其他线程需要等待该线程释放锁才能继续执行,从而保证了共享资源的互斥访问。

2. 基于数据库锁:
   - 在关系型数据库中,悲观锁可以通过使用 SELECT ... FOR UPDATESELECT ... 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. CASCompare and Swap)操作:
   - 使用原子操作 CAS 来实现乐观锁。
   - CAS 包含三个操作数:内存地址 V、期望值 A 和新值 B- 当前线程将期望值 A 和内存地址 V 中的实际值进行比较,如果相等,则将内存地址 V 中的值替换为新值 B;否则,不做任何操作。
   - CAS 是一种无锁算法,可以保证原子性,因此用于实现乐观锁时非常方便。

乐观锁的优点在于它避免了线程之间的阻塞和等待,提高了并发性能。不过,乐观锁也有一些注意事项:

1. 冲突检测:在更新共享资源之前,需要检测其他线程是否已经修改了该资源。如果发现冲突,可能需要进行回滚或重试操作。
2. 数据一致性:乐观锁只是假设并发冲突很少发生,因此不会对共享资源进行加锁保护。这可能导致数据一致性问题,需要开发人员自行处理。

在实际应用中,乐观锁常用于读多写少的场景,例如数据库中的乐观并发控制、缓存更新等。它可以提高并发性能,减少锁竞争,但需要开发人员合理设计并处理冲突和一致性问题。

猜你喜欢

转载自blog.csdn.net/qq_46135702/article/details/136566437