十七 . 并发相关基础概念
可能前面几讲,一些同学理解可以有一些困难,这一篇将进行一些并发相关概念比较模糊,我们将进行并发相关概念的补充,
17.1 线程安全
线程安全就是在多线程的环境下正确的一个概念,保证在多线程的环境下是实现共享的,可修改的状态是正确性,状态可以类比为程序里面的数据。
如果状态不是共享的,或者不是可修改的,就不存在线程安全的问题。
17.2 保证线程安全的两个方法
17.2.1 封装
进行封装,我们将对象内部的状态隐藏,保护起来。
17.2.2 不可变
可以进行final和immutable进行设置。
17.2.2.1 final 和 immutable解释
final
和 immutable
是 Java 中用来描述对象特性的关键字。
final
:用于修饰变量、方法和类。它的作用如下:
- 变量:
final
修饰的变量表示该变量是一个常量,不可再被修改。一旦赋值后,其值不能被改变。通常用大写字母表示常量,并在声明时进行初始化。
- 方法:
final
修饰的方法表示该方法不能被子类重写(覆盖)。 - 类:
final
修饰的类表示该类不能被继承。
immutable
:指的是对象一旦创建后,其状态(数据)不能被修改。不可变对象在创建后不可更改,任何操作都不会改变原始对象的值,而是返回一个新的对象。
不可变对象的主要特点包括:
- 对象创建后,其状态无法更改。
- 所有字段都是
final
和私有的,不可直接访问和修改。 - 不提供可以修改对象状态的公共方法。
不可变对象的优点包括:
- 线程安全:由于对象状态不可更改,因此多线程环境下不需要额外的同步措施。
- 缓存友好:不可变对象的哈希值不会改变,因此可以在哈希表等数据结构中获得更好的性能。
17.3 线程安全的基本特性
17.3.1 原子性(Atomicity)
指的是一系列操作要么全部执行成功,要么全部失败回滚。即一个操作在执行过程中不会被其他线程打断,保证了操作的完整性。
17.3.2 可见性(Visibility)
指的是当一个线程修改了共享变量的值后,其他线程能够立即看到最新的值。需要通过使用 volatile
关键字、synchronized
关键字、Lock
接口等机制来确保可见性。
详细解释:
17.3.2.1 volatile
关键字
当一个变量被声明为volatile时,任何对该变量的修改都会立即被其他线程可见。
当写线程将flag值修改为true后,读线程会立即看到最新的值,并进行相应的操作。这是因为flag变量被声明为volatile,确保了可见性。
public class VisibilityExample { private volatile boolean flag = false; public void writerThread() { flag = true; // 修改共享变量的值 } public void readerThread() { while (!flag) { // 循环等待直到可见性满足条件 } System.out.println("Flag is now true"); } }
17.3.2.2 synchronized
关键字
两个方法都使用synchronized关键字修饰,确保了对flag变量的原子性操作和可见性。当写线程修改flag的值为true后,读线程能够立即看到最新的值。
public class VisibilityExample { private boolean flag = false; public synchronized void writerThread() { flag = true; // 修改共享变量的值 } public synchronized void readerThread() { while (!flag) { // 循环等待直到可见性满足条件 } System.out.println("Flag is now true"); } }
17.3.2.3 Lock
接口
通过使用ReentrantLock实现了显式的加锁和释放锁操作。当写线程获取锁并修改flag的值为true后,读线程也需要获取同样的锁才能看到最新的值。
import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class VisibilityExample { private boolean flag = false; private Lock lock = new ReentrantLock(); public void writerThread() { lock.lock(); try { flag = true; // 修改共享变量的值 } finally { lock.unlock(); } } public void readerThread() { lock.lock(); try { while (!flag) { // 循环等待直到可见性满足条件 } System.out.println("Flag is now true"); } finally { lock.unlock(); } } }
17.3.2.3.1 解释Lock接口:
使用Lock接口进行同步时,通过持有锁可以确保在临界区内的操作是互斥的,即同一时间只能有一个线程执行临界区的代码。这样可以避免多个线程同时对共享变量进行修改带来的问题。
当读线程在访问共享变量之前,发现变量的值不符合预期,即不满足可见性条件时,它会进入循环等待的状态。这样做的目的是等待写线程将最新的值写回共享变量,并使其对其他线程可见。
循环等待的方式可以有效地解决可见性问题。当写线程修改共享变量的值后,它会释放锁。此时,读线程能够重新获取锁并再次检查共享变量的值。如果值已经满足可见性条件,读线程就能够继续执行后续的操作。
需要注意的是,在循环等待的过程中,读线程应该使用适当的等待方式,例如Thread.sleep()或者Lock接口提供的Condition条件对象的await()方法,以避免占用过多的CPU资源。
通过循环等待直到可见性满足条件,可以确保读线程在访问共享变量时能够看到最新的值,从而实现了可见性的要求。
17.3.3 有序性
指的是程序执行的顺序与预期的顺序一致,不会受到指令重排序等因素的影响。可以通过 volatile
关键字、synchronized
关键字、Lock
接口、happens-before
原则等来保证有序性。
例子:
17.3.3.1 volatile
关键字
使用volatile关键字修饰counter变量,确保了对变量的读写操作具有可见性和有序性。其他线程能够立即看到最新的值,并且操作的顺序不会被重排序。
public class OrderingExample { private volatile int counter = 0; public void increment() { counter++; // 非原子操作,但通过volatile关键字确保了可见性和有序性 } public int getCounter() { return counter; // 获取变量的值 } }
17.3.3.2 synchronized
关键字
使用synchronized关键字修饰了increment()和getCounter()方法,确保了对counter变量的原子操作,同时也提供了可见性和有序性的保证。
public class OrderingExample { private int counter = 0; public synchronized void increment() { counter++; // 原子操作,同时具备可见性和有序性 } public synchronized int getCounter() { return counter; // 获取变量的值 } }
17.3.3.3 Lock
接口
通过使用Lock接口实现了显式的加锁和释放锁操作,确保了对counter变量的原子操作,同时也提供了可见性和有序性的保证。
import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class OrderingExample { private int counter = 0; private Lock lock = new ReentrantLock(); public void increment() { lock.lock(); try { counter++; // 原子操作,同时具备可见性和有序性 } finally { lock.unlock(); } } public int getCounter() { return counter; // 获取变量的值 } }
17.3.3.4 happens-before
原则
happens-before是并发编程中的一个概念,用于描述事件之间的顺序关系。在多线程或多进程的环境中,经常会出现多个事件同时发生的情况,而它们之间的执行顺序可能是不确定的。为了确保程序正确地执行,我们需要定义一些规则来解决竞态条件和并发问题。
happens-before关系用于描述事件之间的顺序关系,并指定了一个事件在执行结果上的先于另一个事件。如果一个事件A happens-before 另一个事件B,那么我们可以说事件A在时间上 "早于" 事件B,而事件B在时间上 "晚于" 事件A。
根据Java内存模型(Java Memory Model,简称JMM)的规定。
happens-before关系例子:
- 程序顺序原则(Program Order Rule):在单个线程中,按照程序的顺序,前面的操作 happens-before 后面的操作。
- volatile变量规则(Volatile Variable Rule):对一个volatile域的写操作 happens-before 于后续对该域的读操作。volatile变量的写-读能够确保可见性。
- 传递性(Transitive):如果事件A happens-before 事件B,事件B happens-before 事件C,那么可以推导出事件A happens-before 事件C。通过传递性,可以推断出不同事件之间的happens-before关系。
- 线程启动规则(Thread Start Rule):Thread对象的start()方法调用 happens-before 新线程的所有操作。
- 线程终止规则(Thread Termination Rule):线程的所有操作 happens-before 其他线程中对该线程终止检测的操作。
- 线程中断规则(Thread Interruption Rule):对线程的interrupt()方法的调用 happens-before 所被中断线程中的代码检测到中断事件的发生。
例子:
17.3.3.4.1 线程中断规则(Thread Interruption Rule):
线程A会执行一段任务。在线程A的任务执行的过程中,会循环检查中断状态,当线程B调用线程A的interrupt()
方法进行中断时,线程A会在检查中断状态的代码处发现自己已被中断并返回。这里,线程B的interrupt()
调用和线程A的检查中断状态的操作之间存在一个happens-before关系,保证线程B中的中断操作能被线程A正确检测到。
class MyTask implements Runnable { @Override public void run() { // 执行任务的代码 // ... // 检查中断状态 if (Thread.interrupted()) { // 在此处被中断 return; } // 继续执行任务的代码 // ... } } public class Main { public static void main(String[] args) throws InterruptedException { Thread threadA = new Thread(new MyTask()); threadA.start(); // 主线程等待一段时间后中断线程A Thread.sleep(1000); threadA.interrupt(); } }
17.3.3.4.2 线程终止规则
主线程首先创建一个子线程,并将isRunning
设置为true
,然后子线程进入一个死循环,并在每次循环中检查isRunning
的值。主线程等待2秒后,将isRunning
设置为false
,终止子线程的执行,并使用join()
方法等待子线程终止。最后,主线程打印出"主线程继续执行"。
子线程的终止操作isRunning = false
happens-before 主线程中对isRunning
的读取操作,因此主线程能够观察到子线程的终止,并能够继续执行。这符合线程终止规则。
public class ThreadTerminationExample { private static volatile boolean isRunning = true; public static void main(String[] args) throws InterruptedException { Thread thread = new Thread(() -> { while (isRunning) { // 线程执行的工作... } System.out.println("线程已终止"); }); thread.start(); Thread.sleep(2000); isRunning = false; // 终止线程 thread.join(); // 等待线程终止 System.out.println("主线程继续执行"); } }
happens-before关系的定义保证了程序执行的可见性和有序性,为并发编程提供了一定的保证。开发人员可以利用这些规则来避免竞态条件和并发问题。
17.3.4 互斥性
指的是同一时间只允许一个线程对共享资源进行操作,其他线程必须等待。可以通过使用 synchronized
关键字、Lock
接口来实现互斥性。
17.3.4.1 synchronized
关键字例子:
使用synchronized关键字修饰了increment()和getCount()方法,这意味着同一时间只能有一个线程访问这两个方法。当一个线程在执行increment()方法时,其他线程需要等待,直到当前线程执行完毕才能继续访问。这样可以保证count的操作是原子的,避免了并发访问导致的数据冲突。
public class Counter { private int count = 0; public synchronized void increment() { count++; } public synchronized int getCount() { return count; } }
17.3.4.2 Lock
接口例子:
使用ReentrantLock来创建一个锁,并在increment()和getCount()方法中使用lock()方法获取锁,unlock()方法释放锁。这样同一时间只允许一个线程获取锁并执行代码块,其他线程需要等待锁被释放后才能继续执行,从而实现了互斥性。
无论是使用synchronized关键字还是Lock接口,它们都能够实现互斥性,保证多线程对共享资源的访问是同步的,避免了数据冲突和不一致的问题。但Lock接口相比synchronized关键字更加灵活,可以更精细地控制锁的获取和释放,提供了更多的功能。
import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class Counter { private int count = 0; private Lock lock = new ReentrantLock(); public void increment() { lock.lock(); try { count++; } finally { lock.unlock(); } } public int getCount() { lock.lock(); try { return count; } finally { lock.unlock(); } } }