图文详解:synchronized关键字 及其底层原理


目录

一.线程安全问题

二.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空转浪费资源。




 本次的分享就到此为止了,希望我的分享能给您带来帮助,创作不易也欢迎大家三连支持,你们的点赞就是博主更新最大的动力!如有不同意见,欢迎评论区积极讨论交流,让我们一起学习进步!有相关问题也可以私信博主,评论区和私信都会认真查看的,我们下次再见

相关推荐

  1. Synchronized关键字的底层原理

    2024-05-13 12:14:02       40 阅读

最近更新

  1. docker php8.1+nginx base 镜像 dockerfile 配置

    2024-05-13 12:14:02       94 阅读
  2. Could not load dynamic library ‘cudart64_100.dll‘

    2024-05-13 12:14:02       101 阅读
  3. 在Django里面运行非项目文件

    2024-05-13 12:14:02       82 阅读
  4. Python语言-面向对象

    2024-05-13 12:14:02       91 阅读

热门阅读

  1. MySQL运维总结

    2024-05-13 12:14:02       37 阅读
  2. LeetCode 2391. 收集垃圾的最少总时间

    2024-05-13 12:14:02       38 阅读
  3. MySQL Undo Log、Redo Log、bin Log

    2024-05-13 12:14:02       30 阅读
  4. 哲学家就餐问题

    2024-05-13 12:14:02       34 阅读
  5. OpenCV图像转换处理

    2024-05-13 12:14:02       36 阅读
  6. C++贪心算法

    2024-05-13 12:14:02       35 阅读
  7. vty、带内/带外管理、带内/带外ip简介

    2024-05-13 12:14:02       41 阅读
  8. 未来城市更新要干哪些事?

    2024-05-13 12:14:02       29 阅读
  9. 前端开发如何切换node版本安装依赖

    2024-05-13 12:14:02       32 阅读