目录
一.线程安全问题
二.synchronized关键字
▐ synchronized图解
▐ 可重入锁及图解
▐ synchronized用于方法上
三.Java标准库中synchronized的使用
四.synchronized的底层实现原理
一.线程安全问题
线程安全是指在多线程环境下,对共享资源的访问不会导致数据不一致或出现意外结果的特性。在多线程程序中,多个线程可以同时访问和操作共享数据,如果没有适当的同步机制和保护措施,可能会导致数据竞争和不一致的问题。
线程安全的实现可以通过使用互斥锁、信号量、原子操作等方法来保证。互斥锁可以保证同一时刻只有一个线程可以访问共享资源,其他线程需要等待锁的释放。信号量可以控制同时访问共享资源的线程数量。原子操作是指不可分割的操作,在执行过程中不会被其他线程中断,可以保证数据的一致性。
下面是一个简单的示例代码,展示了线程不安全的情况:
public class UnsafeThreadDemo {
private static int counter = 0;
public static void main(String[] args) {
Thread thread1 = new Thread(new IncrementCounter());
Thread thread2 = new Thread(new IncrementCounter());
thread1.start();
thread2.start();
try {
thread1.join();
thread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Counter: " + counter);
}
static class IncrementCounter implements Runnable {
@Override
public void run() {
for (int i = 0; i < 100000; i++) {
counter++;
}
}
}
}
在这个示例中,我们创建了两个线程,并且它们都执行相同的任务:对 counter
变量进行递增操作。每个线程将 counter
递增 100000 次。我们期望最终的结果是 counter
的值为 200000。然而,由于线程不安全,最终的结果很可能不是我们期望的值。
运行结果:
这是因为线程之间可以并发地访问和修改 counter
变量,而没有任何同步机制来保护它。如果两个线程同时读取并且递增 counter
的值,那么它们可能会读取到相同的值并递增相同的值,导致最终结果比期望的小一些。
要解决上述的问题,我们可以使用同步机制,例如使用 synchronized
关键字或 Lock
接口来保护共享变量的访问。这样可以确保在任何时候只有一个线程能够访问和修改 counter
的值,避免了线程不安全的情况。
二.synchronized关键字
在Java中,synchronized
是一个关键字,用于实现线程同步。当一个方法或一个代码块被synchronized
修饰时,它被称为同步方法或同步代码块。这意味着每次只有一个线程可以进入该方法或代码块,其他线程必须等待,直到当前线程执行完毕并释放锁。
synchronized
关键字的作用是防止多个线程同时执行同步方法或代码块,从而避免竞态条件(race condition)和数据不一致性问题。它确保了多个线程之间的协调和同步,使得共享资源可以被安全地访问和修改。
竞态条件(Race Condition)是指在多线程环境下,由于线程执行顺序的不确定性,导致程序的执行结果不确定或出现错误的情况。简单来说,就是多个线程对共享资源的访问顺序不确定,可能会导致不符合预期的结果。
synchronized
的语法如下:
synchronized(对象) {
//用于保护的代码
}
在使用synchronized
时,需要传入一个对象作为锁。这个对象的具体含义是锁定的对象,也就是说,只有持有该对象的锁的线程才能执行被synchronized
修饰的代码块或方法,其他线程必须等待直到锁被释放。这个对象可以是任意对象,但通常情况下,为了确保正确性和可读性,我们会选择一个特定的对象作为锁。
传入不同的对象就相当于使用了不同的锁。每个对象都有一个相关联的监视器(monitor),也可以说是一个锁。当一个线程进入synchronized
代码块时,它必须先获得与传入对象相关联的监视器,才能执行代码块中的内容。因此,如果你传入不同的对象作为锁,那么这些对象就会对应不同的监视器,也就是说,它们是不同的锁。
这个特性很有用,因为它允许程序员精细地控制哪些代码块需要同步,哪些不需要。比如可以为不同的代码块传入不同的锁对象,以避免它们之间的互相阻塞。
对于刚才的代码,我们使用 synchronized
就可以进行改进,对于每一次 counter
变量递增的时候我们都使用synchronized
对齐进行上锁,保护其中的临界区代码
public class SafeThreadDemo {
private static int counter = 0;
public static void main(String[] args) {
Thread thread1 = new Thread(new IncrementCounter());
Thread thread2 = new Thread(new IncrementCounter());
thread1.start();
thread2.start();
try {
thread1.join();
thread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Counter: " + counter);
}
static class IncrementCounter implements Runnable {
@Override
public void run() {
for (int i = 0; i < 100000; i++) {
synchronized (SafeThreadDemo.class) {
counter++;
}
}
}
}
}
在这个优化后的代码中,我们使用了SafeThreadDemo.class
作为锁对象,以确保只有一个线程能够同时访问counter
变量,从而避免了竞态条件,使得代码线程安全。
▐ synchronized
图解
看完了以上的说明,相信你对synchronized
关键字已经有了较为深刻的理解,用图示可以表示如下
如图,小人就相对于是一个个线程,每个房间则对应了synchronized
关键字的锁对象,不同的锁对象就对应了不同的房间。当线程小人请求进入房间的时候就会进行判断,判断是否能够获取当前的锁对象,如果能获取则让该线程小人进入房间完成该线程对应的工作,并且对这个房间上锁,当其他线程小人来了后就会访问这个房间的锁,如果房间被锁上了,那么该线程小人就会阻塞等待。当房间内部的线程小人完成了他的工作后就会解开房间的锁,从而也就保证了线程的安全性。而不同的房间对应的房间钥匙也就是锁自然也是不一样,这就保证了我们对于资源的灵活分配。
▐ 可重入锁及图解
另外,synchronized
实现的锁属于是可重入锁,还是用这个图示来说明:
当线程1因为时间片的分配等问题临时离开房间,失去了房间的使用权后,线程1为了确保工作的顺利完成,就并没有释放掉锁,当线程1后续被操作系统重新调度进入房间2后,他就可以继续完成之前的手头工作,对于这样的允许一个线程重复进入访问锁直到锁被释放的情况,我们就称之为该锁为可重入锁。
可重入锁(Reentrant Lock)也称为递归锁,是一种线程同步机制。可重入锁允许重复获取同一把锁,使得线程可以在持有锁的情况下再次获取该锁,而不会造成死锁。这种机制使得可重入锁可以用于同步嵌套的代码块。
可重入锁的内部实现通常会维护一个线程持有锁的计数器,并记录当前持有锁的线程。当一个线程首次尝试获取锁时,计数器会增加,并记录该线程。如果同一个线程再次尝试获取锁,计数器会递增,而不是阻塞。只有当计数器归零时,锁才会释放,允许其他线程获取锁。
▐ synchronized
用于方法上
synchronized
也可以直接作用于成员方法之上,相对于锁住的就是this对象,例如
class Test {
public synchronized void test() {
}
}
//等价于
class Test {
public void test() {
synchronized (this) {
}
}
}
它也可以作用于静态方法上,它锁住的相对于就是类对象
class Test {
public synchronized static void test() {
}
}
//等价于
class Test {
public static void test() {
synchronized (Test.class) {
}
}
}
三.Java标准库中synchronized的使用
Java 标准库中很多都是线程不安全的. 这些类可能会涉及到多线程修改共享数据, 又没有任何加锁措施,比如:
- ArrayList
- LinkedList
- HashMap
- TreeMap
- HashSet
- TreeSet
- StringBuilder
但是还有一些是线程安全的. 使用了一些锁机制来控制.
- Vector
- HashTable
- ConcurrentHashMap
- StringBuffer
还有的虽然没有加锁, 但是不涉及 "修改", 仍然是线程安全的
- String
比如在StringBuffer的核心方法中,基本上都加的有 synchronized
四.synchronized的底层实现原理
synchronized也可在底层的实现主要依赖于锁监视器monitor,在Java中,monitor是一种同步机制,用于保护共享资源的线程安全性。
Java中的monitor是通过内置锁(也称为监视器锁)来实现的。每个Java对象都可以关联一个Monitor对象,我们称之为内置锁,当一个线程进入synchronized方法或块时,它会自动获取该对象的内置锁,并在执行完synchronized代码段后释放锁。这种机制确保了同一时刻只有一个线程可以访问被synchronized保护的代码。只有在持有monitor锁的线程释放锁后,其他线程才能获取锁并执行对共享资源的访问。
这样的说明未免有点枯燥不好理解,笔者这里还是给出图示:
对于每一个Java对象都可以绑定一个Monitor对象(锁),当多个线程来执行被synchronized修饰的同步代码块时,根据JDK的调度机制会选取其中一个线程来作为该对象绑定的Monitor对象的拥有者(Owner),并且一个Monitor对象只能有一个锁主人(Owner),然后该线程便获得了执行该同步代码块的权利,而对于那些没有被选中的线程则会放入一个等待队列(EntryList)中进行等待,只有当前线程完成工作后才会更新调度规则选出新的Owner。
需要注意的是,Java中的monitor是一种高级抽象,实际上是由底层的操作系统提供的同步原语来实现的。
▐ 基于锁策略的synchronized原理
以上关于synchronized的讲解是属于在代码层次上的原理,关于锁还有一部分很重要的就是锁的策略,尤其对于synchronized来说,她有以下的一些特性:
- 1. 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
- 2. 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.
- 3. 实现轻量级锁的时候大概率用到的自旋锁策略
- 4. 是一种不公平锁
- 5. 是一种可重入锁
- 6. 不是读写锁
JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次升级。
在这里对这些名词简单的解释一下,更具体的信息则需要锁策略相关的知识来说明:
锁策略详解:互斥锁、读写锁、乐观锁与悲观锁、轻量级锁与重量级锁、自旋锁、偏向锁、可重入锁与不可重入锁、公平锁与非公平锁-CSDN博客
偏向锁
第一个尝试加锁的线程, 优先进入偏向锁状态,偏向锁不是真的 "加锁", 只是给对象头中做一个 "偏向锁的标记", 记录这个锁属于哪个线程,如果后续没有其他线程来竞争该锁, 那么就不用进行其他同步操作了(避免了加锁解锁的开销),如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别当前申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进入一般的轻量级锁状态。偏向锁本质上相当于 "延迟加锁" . 能不加锁就不加锁, 尽量来避免不必要的加锁开销,但是该做的标记还是得做的, 否则无法区分何时需要真正加锁。
自旋锁
自旋锁是一种基于循环重试的锁机制,在多线程编程中用于实现对共享资源的互斥访问。当一个线程尝试获取自旋锁时,如果锁已被其他线程持有,该线程不会立即进入阻塞状态,而是在循环中不断尝试获取锁,直到成功获取为止,或者达到最大尝试次数后才会放弃。
轻量级和重量级锁
轻量级锁是为了在多线程竞争情况下,提高性能而引入的一种锁优化技术。当一个线程尝试获取锁时,如果锁没有被其他线程占用,虚拟机会在当前线程的栈帧中使用 CAS 操作尝试将对象头部的 Mark Word 替换为指向当前线程的锁记录指针(Lock Record Pointer)。如果 CAS 操作成功,当前线程就获得了锁,并且锁的状态被标记为轻量级锁。此时其他线程访问同步块时会尝试自旋等待,而不是直接阻塞,以减少线程切换的开销。如果自旋等待一段时间后仍无法获取锁,或者其他线程争用激烈,CAS 操作失败,那么轻量级锁会膨胀为重量级锁。当轻量级锁膨胀失败时,锁会升级为重量级锁。重量级锁会使其他线程阻塞,而不是进行自旋等待,防止CPU空转浪费资源。
本次的分享就到此为止了,希望我的分享能给您带来帮助,创作不易也欢迎大家三连支持,你们的点赞就是博主更新最大的动力!如有不同意见,欢迎评论区积极讨论交流,让我们一起学习进步!有相关问题也可以私信博主,评论区和私信都会认真查看的,我们下次再见