文章目录
- Java内置锁的核心原理-Synchronized
- 1.线程安全问题
- 1.1.自增运算分析
- 1.2.临界区资源和临界区代码片段
- 2.synchronized关键字
- 2.1.synchronized同步方法
- 2.2.synchronized同步代码块
- 2.3.synchronized同步方法和synchronized同步代码块区别
- 2.4.静态的同步方法
- 2.5.内置锁的释放
Java内置锁的核心原理-Synchronized
Java内置锁是一个互斥锁,这就意味着多个线程只有一个线程能够获取到该锁,当线程B尝试获取A持有的内置锁时,B就必须阻塞或者等待,直到A线程释放这个锁,不然B线程就是会一直等待下去。
Java中的每个对象都可以用作锁,这些锁被称为内置锁,线程进入同步代码块或者方法时,会自动获得该锁,在退出同步代码块或者方式时,会自动释放该锁。获得内置锁的唯一途径就是进入这个锁保护的同步代码块或者同步方法。
Java内置锁是指在Java语言中提供的一种同步机制,用于在多线程环境下保护共享资源的访问。它是通过关键字
synchronized
来实现的。Java内置锁的实现涉及到以下几个方面:
- 对象监视器(Object Monitor):每个Java对象都与一个对象监视器关联。对象监视器是用于实现同步的基本结构,可以看作是一个互斥锁。每个对象监视器都有一个相关的等待集(wait set)和一个锁持有者。
- 互斥性(Mutual Exclusion):Java内置锁提供了互斥性,即同一时间只能有一个线程持有对象监视器的锁。当一个线程获取到锁后,其他线程就无法进入被锁保护的代码块,只能等待锁的释放。
- 可重入性(Reentrancy):Java内置锁是可重入的,也就是同一个线程可以多次获取同一个对象监视器的锁。这种机制允许线程在持有锁的情况下再次进入被锁保护的代码块,而不会被自己所持有的锁所阻塞。
- 等待与通知机制(Wait and Notify):Java内置锁提供了等待与通知机制,允许线程在获取锁之后,如果条件不满足,可以主动释放锁并进入等待状态,直到其他线程通知条件发生变化后再次竞争获取锁。这个机制通过
wait()
和notify()
、notifyAll()
等方法实现。- 内存可见性(Memory Visibility):Java内置锁通过加锁和解锁的操作,可以确保共享变量的可见性。当一个线程释放锁时,会将对共享变量的更新刷新到主内存中,使得其他线程可以看到最新的值。
Java内置锁的使用方式是通过
synchronized
关键字来修饰代码块或方法。当一个线程进入被synchronized
修饰的代码块或方法时,它会尝试获取对象监视器的锁,如果锁已经被其他线程持有,则该线程会进入阻塞状态,直到锁被释放。一旦线程获取到锁,就可以执行被锁保护的代码,并且在执行完毕后释放锁,以允许其他线程进入。下面我们先从线程安全问题看起,逐渐了解Java内置锁的核心原理
1.线程安全问题
在Java中,线程安全问题是由多线程并发访问共享资源而引起的。在多线程环境中,多个线程可以同时访问和修改共享数据,而不同线程之间的执行顺序是不确定的。这种并发访问可能导致数据的不一致性、错误的计算结果以及线程间的竞争条件。
线程安全问题的出现主要是由于以下几个原因:
- 共享数据:当多个线程同时访问和修改同一个共享数据时,就会引发线程安全问题。共享数据可以是全局变量、静态变量、对象的成员变量等。
- 竞态条件:竞态条件是指多个线程在执行过程中,由于执行顺序的不确定性而导致结果的正确性依赖于线程执行顺序的问题。竞态条件可能会导致数据的不一致性和错误的计算结果。
- 数据竞争:数据竞争是指多个线程同时访问共享数据,并且至少有一个线程对该数据进行了写操作,而没有适当的同步机制来保证线程之间的顺序和一致性。数据竞争可能导致数据的不一致性和未定义行为。
- 缺乏同步机制:在多线程环境中,如果没有适当的同步机制来保护共享资源的访问,就会导致线程之间的竞争条件和数据竞争,从而引发线程安全问题。
线程安全问题可能会导致程序的不可预测行为、数据的不一致性、性能下降以及安全漏洞。为了解决线程安全问题,需要采取适当的同步机制来保护共享资源的访问,例如使用锁、原子操作、同步容器、并发工具类等。此外,还需要注意避免死锁、活锁等并发陷阱,设计线程安全的类和方法,以及进行适当的测试和调优。
下面我们通过一个小实验来看下线程的安全问题
我们使用10个线程,对一个共享变量进行自增运算,每个线程自增1W次,我们最终来看看下这个共享变量的结果
/**
* 使用10个线程,对一个共享变量进行自增运算,每个线程自增1W次,我们最终来看看下这个共享变量的结果
*/
@Slf4j
public class TestAdd {
private int count = 0;
// private Object lock = new Object();
@Test
@DisplayName("测试自增")
public void testAdd() {
ExecutorService executorService = Executors.newFixedThreadPool(10);
for (int i = 0; i < 10; i++) {
executorService.submit(() -> {
for (int j = 0; j < 10000; j++) {
//synchronized (lock){
count++;
//}
}
});
}
// 休眠10s
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
executorService.shutdown();
log.error("预期值:100000");
log.error("最终结果:{}", count);
}
}
1.1.自增运算分析
为什么自增运算不是线程安全的呢?实际上,一个自增运算符是一个符合操作,至少包括3个JVM指令,内存取值
,寄存器加1
,存值到寄存器
,这三个指令在JVM中操作时独立进行的。
在现代计算机的CPU中,存在一个叫做流水线(pipeline)的机制。流水线可以同时执行多条指令,将指令的执行分为多个阶段,并行地处理不同指令的不同阶段。例如,流水线可以将指令的取指、译码、执行、访存和写回等阶段分开处理。
对于自增运算,它通常包括以下步骤
- 内存取值
- 寄存器加1
- 存值到寄存器
这个三个JVM指令是不可以在分的,他们都具有原子性,是线程安全的,但是两个或者两个以上原子操作结合在一起,那么就不具备原子性,不如先读后写,先写后读都有可能导致结果不一致
在单线程环境下,这些步骤会按顺序执行,并且每一步的结果都会影响下一步的执行。但在多线程环境下,多个线程可能同时执行自增操作,导致以下问题:
- 可见性问题:当一个线程执行自增操作时,它首先需要读取变量的当前值。然而,由于CPU的高速缓存和指令重排等优化机制,线程可能会从自己的缓存中读取变量的值,而不是从主内存中读取。这导致不同线程看到的变量值可能不一致,从而引发不正确的结果。
- 写回问题:在流水线中,写回操作可能会延迟执行,不会立即将结果写回主内存。这就意味着,当一个线程完成自增操作并将结果写回变量时,其他线程可能仍然在流水线中的前面阶段执行自增操作,导致结果的覆盖或丢失。
综上所述,自增运算在多线程环境下存在可见性问题和写回问题,这可能导致多个线程对同一个变量的自增操作相互干扰,导致不正确的结果。为了解决这个问题,可以使用同步机制(如锁或原子操作)来确保自增操作的原子性,或者使用线程安全的数据结构来避免竞态条件。
1.2.临界区资源和临界区代码片段
在后端开发中,我们常常认为代码会以线程的,串行的方式执行,容易忽视多个线程并发执行,从而导致意想不到的结果.下面我们了解并发中几个重要概念,临界区资源
,
临界区代码片段
,互斥机制
,竞态条件
-
临界区资源:
临界区资源是指在并发编程环境中由多个线程或进程共享的数据或资源
- 临界区资源是被多个线程或进程共享的数据或资源。
- 临界区资源包括共享变量、数据结构、文件、设备等。
- 多个线程或进程同时访问临界区资源可能导致数据不一致或竞态条件。
-
临界区代码片段:
临界区代码片段是指程序中访问临界区资源的代码段。
- 临界区代码片段通常是对共享资源进行读取、写入或修改的部分。
- 需要使用互斥机制来保证对临界区资源的互斥访问。
- 临界区代码片段需要被互斥锁(或其他互斥机制)保护。
- 只有获得互斥锁的线程或进程才能执行临界区代码片段。
-
互斥机制:
互斥机制是用于实现对临界区资源的互斥访问的同步机制。
- 互斥机制确保在任意时刻只有一个线程或进程能够访问临界区资源。
- 常用的互斥机制包括互斥锁、信号量、条件变量等。
- 互斥机制可以避免竞态条件和数据不一致的问题。
-
竞态条件:
竞态条件是指在多线程或多进程环境中,由于不恰当的执行顺序而导致程序的行为不确定或产生错误的现象。
- 多个线程或进程同时访问、读取或修改的共享数据或资源。
- 多个线程或进程以无法预测的顺序并发执行,可能会相互干扰。
- 缺乏适当的同步机制或同步策略,无法保证对共享资源的互斥访问。
在并发编程中,临界区资源是受到保护的对象的,临界区代码段是每个线程访问临界资源的代码段,多个线程必须互斥的进行临界区资源进行访问.
线程进入临界区代码段必须先申请资源,申请成功后才能进入临界区代码段,执行完毕释放资源,具体如图
上面的案例中,我们就是没有临界区代码进行保护,导致结果和我们的预期相差较大,导致多个线程同时访问一个资源时,出现了竞态条件。
为了避免出现竞态条件,我们必须保证,临界区代码段操作,必须具有互斥性或者称为排他性(同一时刻只有一个线程能进行操作),在Java中有很多方式可以使用,这里我们使用synchronized来对临界区代码段进行排他性保护。再次运行观察结果
private Object lock = new Object();
synchronized (lock){
count++;
}
2.synchronized关键字
在Java开发中,线程同步使用的最多的就是synchronized关键字,每个Java对象都隐含一把锁,这个锁被称为Java内置锁(对象锁,隐式锁)。使用synchronized调用相当于获取这把内置锁,这样就可以对临界区代码进行排他性保护。
2.1.synchronized同步方法
synchronized同步方法是一种在Java中用于实现线程安全的机制。它提供了一种简单而强大的方式来确保多个线程对共享资源的互斥访问。当使用synchronized关键字来修饰一个方式的时候,该方法被声明为一个同步方法。使用方式也很简单
// 声明为同步方法
private synchronized void incrementing() {
for (int j = 0; j < 10000; j++) {
count++;
}
}
再次运行程序
@Test
@DisplayName("测试synchronized同步方法")
public void test2() {
ExecutorService executorService = Executors.newFixedThreadPool(10);
for (int i = 0; i < 10; i++) {
executorService.submit(() -> {
incrementing();
});
}
// 休眠10s
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
executorService.shutdown();
log.error("预期值:100000");
log.error("最终结果:{}", count);
}
private synchronized void incrementing() {
for (int j = 0; j < 10000; j++) {
count++;
}
}
synchronized同步方法基于Java中的内置锁(也称为监视器锁)实现了线程的互斥访问。每个Java对象都与一个内置锁相关联。当一个线程进入synchronized同步方法时,它会自动获取该对象的内置锁。其他线程必须等待该锁的释放才能进入同步方法。
这样看似解决了问题,但是我们还要从并发性能角度来考虑,在synchronized同步方法中,默认情况下,锁的粒度是对象级别的。这意味着当一个线程执行同步方法时,其他线程无法同时执行该对象的同步方法。这种粒度适用于需要保护整个对象状态的场景,但可能导致较大的锁竞争和阻塞。
下面我们来了解一下synchronized同步代码块
2.2.synchronized同步代码块
对于较大的临界区代码段,为了保证执行的一个效率,我们最好将同步方法设置为小的临界区。
synchronized同步代码块是使用synchronized关键字来标识的一段代码,用于保护临界区,确保在同一时间只有一个线程可以执行该代码块。与synchronized同步方法不同,同步代码块只锁定指定的对象,而不是整个方法或对象。
通过仅在必要的代码段中使用synchronized同步代码块,可以减小同步区域的范围。这样可以提高并发性,因为其他线程不需要等待整个方法的执行完成,而只需要等待同步代码块执行完成。
// 主要此时锁的是是一个对象(obj),而不是一整个方法了
// 每当线程进入临界区代码段时,需要获取Obj对象的监视锁。因为每个对象都有一把监视锁,因为任何Java对象都可以作为synchronized的同步锁
synchronized(obj){
// 临界区代码保护段
........
}
下面我们通过一个案例来了解下synchronized同步代码块
private int count = 0;
private Object lock = new Object();
@Test
@DisplayName("测试synchronized同步代码块")
public void test3() {
long l = System.currentTimeMillis();
executeTask();
log.error("预期值:100000000");
log.error("最终结果:{}", count);
log.error("执行花费时间:{} ms", System.currentTimeMillis() - l);
}
private void executeTask() {
// 创建一个线程集合
ArrayList<Thread> threads = new ArrayList<>();
for (int i = 0; i < 10; i++) {
threads.add(new Thread(() -> {
add();
}));
}
for (Thread thread : threads) {
thread.start();
}
threads.forEach(it -> {
// 等待其他线程全部执行完毕
try {
it.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
}
private void add() {
// 将锁的粒度控制再最小范围内
synchronized (lock) {
for (int j = 0; j < 10000000; j++) {
count++;
}
}
}
这段代码中的synchronized
同步代码块,它被用来保证对count
变量的并发访问的线程安全。
executeTask()
方法中创建了10个线程,并启动它们执行add()
方法。- 每个线程在
add()
方法中,通过synchronized
同步代码块对count
变量进行10000000次的增加操作。 synchronized
同步代码块的锁对象是lock
,多个线程在执行时会竞争这个锁对象。- 当一个线程获取到锁对象后,其他线程需要等待锁的释放才能继续执行。
- 由于
synchronized
同步代码块的存在,每个线程在执行增加操作时都会获得锁,这样可以保证对count
变量的操作是互斥的,避免了并发访问导致的数据不一致性问题。 - 最终,所有线程执行完毕后,测试方法输出了预期值和最终结果的日志信息,以及执行花费时间的日志信息。
根据代码的逻辑,预期结果应该是count
的最终值为 100000000,因为每个线程都对count
进行了 10000000 次的增加操作。
需要注意的是,由于synchronized
同步代码块的锁粒度被控制在最小范围内,每个线程只在增加操作时获取锁,因此可以提高并发性能,减少了线程之间的竞争。这样可以确保线程之间的并发执行,同时保证了对count
变量的线程安全访问。
2.3.synchronized同步方法和synchronized同步代码块区别
synchronized同步方法
和synchronized同步代码
块有什么联系呢?其实在Java内部实现上,synchroinzed方法 等同于用一个synchronized代码块
,只不过这个代码块包含了同步方法中的所有语句
。然后再synchroinzed代码块中传入的是 this关键字
下面这个两种同步方式 通过
JVM内部字节码编译后其实是 一致的
private void add() {
// 将锁的粒度控制再最小范围内
synchronized (this) {
for (int j = 0; j < 10000000; j++) {
count++;
}
}
}
private synchronized void add() {
// 将锁的粒度控制再最小范围内
for (int j = 0; j < 10000000; j++) {
count++;
}
}
2.4.静态的同步方法
在Java中有两种对象:Object实例对象和Class对象,每个类运行的时的类型用Class对象表示,它包含类名称,继承关系,字段,和方法相关信息。
JVM将类载入自己的方法区内存时,会为其创建一个Class对象,对于一个类来说Class对象是唯一的
Class对象没有公共的构造方法,Class对象是在类加载的时候由Java虚拟机调用类加载器中的defineClass方法自动构造的,因为不能显式的声明一个Class对象
下面是一个synchronized关键字修饰static静态方法的例子
private synchronized static void add() {
for (int j = 0; j < 10000000; j++) {
count++;
}
}
静态同步方法的特点:
- 锁对象:静态同步方法的锁对象是该方法所属的类对象,即类的Class对象。每个类在Java虚拟机中都有唯一的Class对象,它在类加载过程中被创建。因此,不同的类拥有不同的Class对象,不同的类对象之间的锁是互相独立的。
- 静态方法属于Class实例,而不是单个Object实例,而且静态方法内部也不可以访问Object实例的this引用(也称为指针,或者说句柄),所有当使用synchroinzed关键字修饰静态方法时,我们是没法获取的Object实例的this对象监视锁的。
- 锁范围:静态同步方法的锁范围是整个方法体。当一个线程进入静态同步方法时,它会尝试获取该类对象上的锁,其他线程需要等待锁的释放才能执行该方法。
- 使用synchronized关键字修饰的静态方法时,一个JVM所有争用线程 共同去竞争同一把锁,这个锁的粒度时非常粗的。
- 如果使用对象锁,那么JVM所有争用对象,竞争的是不同的对象锁,争用线程可以同步进入临界区,所得粒度是很细的。**
- 影响范围:静态同步方法影响的是该类的所有实例对象。当一个线程获得了静态同步方法的锁时,其他线程无法同时访问该类的其他静态同步方法,但可以同时访问该类的非静态同步方法或非同步方法。这是因为静态同步方法使用的是类对象作为锁对象,而实例方法使用的是实例对象作为锁对象。
- 类级别同步:静态同步方法实际上是在类级别上进行同步。通过静态同步方法,可以保证多个线程对该类的静态变量的并发访问是线程安全的。这对于需要保护类级别共享资源的场景非常有用。
- 静态同步方法与实例方法的区别:静态同步方法和实例方法之间的同步是互相独立的。静态同步方法使用的是类对象作为锁对象,而实例方法使用的是实例对象作为锁对象。因此,静态同步方法和实例方法可以在多线程环境下同时执行,它们之间的锁不会互相影响。
2.5.内置锁的释放
通过synchronized抢占的锁,什么时候释放呢?
当一个线程通过synchronized
关键字抢占到锁时,它会执行同步代码块或同步方法中的代码。锁会在以下几种情况下释放:
- 正常执行完成:当线程执行完同步代码块或同步方法中的所有语句,即执行到代码块的末尾或方法的返回处,锁会自动释放。这样,其他线程就有机会获得锁并执行相应的同步代码块或同步方法。
- 异常情况:如果在同步代码块或同步方法中抛出了未被捕获的异常,锁也会被释放。异常会终止当前线程的执行,因此在异常发生时,JVM会确保锁的释放,以允许其他线程继续执行。
- 调用
wait()
方法:当线程在同步代码块或同步方法中调用了对象的wait()
方法,它会释放锁并进入等待状态。在等待期间,该线程会让出锁给其他线程,直到被其他线程通过notify()
或notifyAll()
方法唤醒后,才会重新竞争锁。 - 调用
notify()
或notifyAll()
方法:当线程在同步代码块或同步方法中调用了对象的notify()
或notifyAll()
方法,它会唤醒等待在该对象上的一个或多个线程。被唤醒的线程会重新竞争锁,一旦获得锁,就可以继续执行同步代码块或同步方法。
需要注意的是,锁的释放是自动进行的,程序员不需要显式地释放锁
。当锁被释放后,其他线程可以竞争获取锁并执行相应的同步代码块或同步方法。这种锁的释放机制保证了多个线程之间的互斥访问,从而实现了同步和线程安全。