这篇小记偏向于理论,即 Java 底层是怎么实现互斥同步

锁是信号量的一个子集,目的就是通过 PV 操作控制信号量从而保护临界区中的临界资源

信号量是锁的超集,而PV操作是操作信号量的原语。锁(互斥锁)是信号量的一种特殊用法(初始值为1的信号量)。它们共同的目标是为了保护临界区,从而实现安全地访问临界资源。PV 操作的作用就是为了完成同步和互斥这两个功能

PV 操作是 OS 自带的原语操作,内核才有防止中断的权限,并发和并行是产生数据不同步的根本原因,PV 操控通过控制信号量来互斥地访问临界区中的临界资源,而锁是信号量的一个子集

题记

进程之间为什么要实现互斥:不同进程之间可能会共享某项资源。

进程之间为什么要实现同步:不同进程之间的操作可能会存在先后关系

临界资源 && 临界区

临界资源被存放在临界区中,OS 可以通过访问临界区去访问对应的临界资源

  1. 临界资源

临界资源是一次仅允许一个进程使用的共享资源。各进程采取互斥的方式,实现共享的资源称作临界资源。

属于临界资源的硬件有,打印机,磁带机等;软件有消息队列,变量,数组,缓冲区等。诸进程间采取互斥方式,实现对这种资源的共享。

  1. 临界区:

每个进程中访问临界资源的那段代码称为临界区(criticalsection),每次只允许一个进程进入临界区,进入后,不允许其他进程进入。不论是硬件临界资源还是软件临界资源,多个进程必须互斥的对它进行访问。

多个进程涉及到同一个临界资源的的临界区称为相关临界区。

使用临界区时,一般不允许其运行时间过长,只要运行在临界区的进程还没有离开,其他所有进入此临界区的进程都会被挂起而进入阻塞状态,并在一定程度上影响程序的运行性能。

临界资源一般被放置在内存中,在一台计算机中的处理机读取的是同一个内存。

同步机制应当遵循的准则:

  • 空闲让进:临界区空闲时,可以允许一个请求进入临界区的进程立即进入临界区
  • 忙则等待:当已经有进程进入临界区时,其他试图进入临界区的进程必须等待
  • 有限等待:对请求访问临界区的进程,应保证其在有限的时间内进入临界区
  • 让权等待:当进程不能进入临界区时,应立即释放处理机

原语操作

原语操作指的是一系列操作从开始执行到结束都不允许被中断

并发 & 并行

并发和并行的区别仅在于“是否拥有多个处理机”

从宏观来看,并发与并行属于同时运行的,多个进程之间处于异步,没有区别

从微观来看:

  • 并发:发生在一个处理机上的多个进程,在一个时间段内,有多个任务都处于启动到运行完毕之间的状态,这些任务在同一个处理器上运行。
  • 并行:发生在多个处理机上的多个进程,在一个时间段内,多个处理器或者多核处理器上同时处理多个任务。

如下图:

并行

X 轴是时间;Y 轴是进程

并发

X 轴是时间;Y 轴是进程

并发就是一个处理器去分神处理多个进程

并行就是多个处理器专一去处理多个进程

来个比喻:并发是一个人同时吃三个馒头,而并行是三个人同时吃三个馒头。

宏观角度来看,都没了三个馒头,但从微观的角度看就不一样了

信号量 - “数据 / 计数器”

它本质上就是一个整数变量,用来记录某项资源的数量。

信号量是 OS 提供的一种机制,用于表示临界资源的数量

比如 int count = 5

PV 操作 - “动作 / 方法”

这是对信号量进行操作的唯一途径(原语):

P (Proberen = 尝试/申请)--- 申请一个资源,如果资源不够就阻塞等待(进入阻塞队列)

P操作的底层,是申请并获得了对“信号量”这个共享数据结构的独占修改权。利用这个独占权,它原子地完成了对资源状态的判断和更新,从而决定了当前进程是否有权访问真正的临界资源

V (Verhogen = 增加/释放) --- 释放一个资源,如果有进程在等待该资源,则唤醒一个进程

V操作的底层则是返还了“信号量”这个共享数据结构,在“信号量”这个共享数据结构被更新后,如果当前“信号量”仍为负数(代表当前有其他进程正在请求这个共享数据结构,并处于阻塞状态)就将阻塞队列的头进程唤醒,它会进入就绪态

锁 - “特例 / 应用”

锁是一种特殊的信号量,它的资源只有 1(即:要么是 0,要么是 1)。

所以,互斥 = 初始值为 1 的信号量。

在 Java 中,ReentrantLock 就是把 PV 操作封装成了 lock() 和 unlock() 方法给程序员使用。

Java 是怎么使用同步和互斥的?

锁(Lock)不是资源本身,锁是访问资源的“入场券”。

显式锁 - ReentrantLock - 互斥量

在 Java 中可以使用 Lock.lock 对一块地方上锁(其实是 JVM 向 OS 申请了一块互斥空间/临界资源),在 Java 中,变量可以被放置到临界资源区中,这样子来实现锁

例如

// 1. 【抢钥匙】
// 这一步不是申请资源,而是“申请访问资源的权限”。
// 如果拿到钥匙,就进门;拿不到,就在门口排队(阻塞)。
lock.lock(); 

try {
    // 2. 【临界区 (Critical Section)】
    // 只有拿到了钥匙的线程,才能运行这几行代码。
    // 在这里,你可以安全地操作“临界资源”(比如 map.put, count++)。
    // 此时,别人进不来,因为钥匙在你兜里。
    map.put("key", 1); 

} finally {
    // 3. 【还钥匙】
    // 这一步不是把资源还给 OS(厕所还在),而是“归还权限”。
    // 把钥匙挂回墙上,通知门口排队的人:“我用完了,你们抢吧”。
    lock.unlock(); 
}

其实 Java 在 lock 的底层就已经实现了阻塞了,那么这个时候就有人要问了,老师老师,那我们(Condition)呢?

一句话总结:阻塞 不等于 释放锁。只有 Condition.await() 才能做到 “在阻塞的同时释放锁”。

把话说得更日常一点:你在食堂吃饭,一般来讲就是 先抢位置(抢锁) -> 去打饭(调用资源) -> 吃饭(使用资源) -> 离开(释放资源和锁) 这一个过程

lock 相当于你抢了一个位置,然后在排打饭队伍,快到你了告诉你:小伙子不好意思啊,饭没了,你得等一下了,condition 的做法是你先把座位放开,让其他已经打了饭同学可以坐,然后你去等阿姨跟你说,饭好了,然后你再去抢座位,抢饭。

如果没有 condition,相当于阿姨跟你说没饭了,你说:那没事儿,我等等,但是座位还是你的啊,其他要吃饭的人就没座位了,那不就是占着茅坑不拉屎了吗?

隐式锁 - synchronized - 管程

在 JDK 编写过程,开发者就已经给每一个对象上了一把隐式锁

隐式锁可以通过添加 synchronized 关键字开启

Java中为什么基本数据类型不能加锁

简单来说,之所以基本数据类型不能加锁的原因是:基本数据类型的大小是固定的,没有多余的空间去放锁头

死锁

由多个并发进程因争夺系统资源而产生相互等待的现象

死锁产生的四个必要条件

  • 互斥:某种资源一次只允许一个进程访问,即该资源一旦分配给某个进程,其他进程就不能再访问,直到该进程访问结束。
  • 占有且等待:一个进程本身占有资源(一种或多种),同时还有资源未得到满足,正在等待其他进程释放该资源。
  • 不可抢占:别人已经占有了某项资源,你不能因为自己也需要该资源,就去把别人的资源抢过来。
  • 循环等待:存在一个进程链,使得每个进程都占有下一个进程所需的至少一种资源。

如何预防和避免死锁

预防死锁:可以通过破坏死锁产生的4个必要条件来 预防死锁

死锁避免:在使用前进行判断,只允许不会产生死锁的进程申请资源;

结语

在 Java 中,锁是 JDK 底层请求 OS 对临界区中的临界资源进行 PV 操作的实际操作,对于多线程和高并发来讲十分重要。

锁是一种同步机制,用于在存在资源竞争的环境中,强制限制对资源的访问顺序。它可以被理解为一个“令牌”或“许可证”,谁拿到了这个令牌,谁就有权访问被保护的资源。

锁的逻辑围绕着两个基本操作:获取锁 和 释放锁,以及它们之间的代码区域——临界区。

本站提供的所有下载资源均来自互联网,仅提供学习交流使用,版权归原作者所有。如需商业使用,请联系原作者获得授权。 如您发现有涉嫌侵权的内容,请联系我们 邮箱:alixiixcom@163.com