Java并发编程的艺术 -- Java中的锁(第五章)

简介: Java中的锁(第五章)
本文参考于《Java并发编程的艺术》

1、Lock接口

1.1、锁说明

用来控制多个线程访问共享资源的方式,一般来说,一个锁能够防止多个线程同时访问共享资源(但是有些锁可以允许多个线程并发的访问共享资源,比如读写锁)。

1.2、Lock接口和synchronized关键字的对比

  1. Lock接口:它提供了与synchronized关键字类似的同步功能,只是在使用时需要显式地获取和释放锁。虽然它缺少了(通过synchronized块或者方法所提供的)隐式获取释放锁的便捷性,但是却拥有了锁获取与释放的可操作性、可中断的获取锁以及超时获取锁等多种synchronized关键字所不具备的同步特性。
  2. synchronized关键字:使用synchronized关键字将会隐式地获取锁,但是它将锁的获取和释放固化了,也就是先获取再释放。当然,这种方式简化了同步的管理,可是扩展性没有显示的锁获取和释放来的好

1.3、Lock接口的使用

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class locks {
    public static void main(String[] args) {
        Lock lock = new ReentrantLock();

        new Thread(()->{
            lock.lock();

            try{
                System.out.println("t1.....");
                Thread.sleep(1000L);
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }
        },"t1").start();

        new Thread(()->{
            lock.lock();

            try{
                System.out.println("t2.....");
                Thread.sleep(1000L);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }finally {
                lock.unlock();
            }
        },"t2").start();
    }
}

输出结果

在这里插入图片描述

使用说明

  • 在finally块中释放锁,目的是保证在获取到锁之后,最终能够被释放
  • 不要将获取锁的过程写在try块中,因为如果在获取锁(自定义锁的实现)时发生了异常,异常抛出的同时,也会导致锁无故释放

1.4、Lock的API

方法名称 描述
void lock() 获取锁,调用该方法当前线程将会获取锁,当锁获得后,从该方法返回
void lockInterruptibly() throws InterruptedException 可中断地获取锁,和 lock()方法的不同之处在于该方法会响应中断,即在锁的获取中可以中断当前线程
boolean tryLock() 尝试非阻塞的获取锁,调用该方法后立刻返回,如果能够获取则返回true,否则返回false
boolean tryLock(1ong time,TimeUnit unit) throws InterruptedException 超时的获取锁,当前线程在以下3种情况下会返回:①当前线程在超时时间内获得了锁 ②当前线程在超时时间内被中断 ③超时时间结束,返回false
void unlock() 释放锁
Condition newCondition() 获取等待通知组件,该组件和当前的锁绑定,当前线程只有获得了锁,才能调用该组件的 wait()方法,而调用后,当前线程将释放锁

2、队列同步器

2.1、什么是队列同步器?

  • 队列同步器(以下简称同步器),是用来构建锁或者其他同步组件的基础框架,它使用了一个int成员变量表示同步状态通过内置的FIFO队列来完成资源获取线程的排队工作
  • 同步器的主要使用方式是继承,子类通过继承同步器并实现它的抽象方法来管理同步状态

2.2、队列同步器来访问或修改同步状态的方法

  • getState():获取当前同步状态。
  • setState(int newState):设置当前同步状态。
  • compareAndSetState(int expect,int update):使用CAS设置当前状态,该方法能够保证状态设置的原子性

2.3、同步器可重写的方法

在这里插入图片描述

2.4、同步器提供的模板方法

在这里插入图片描述

2.5、独占锁说明

独占锁就是在同一时刻只能有一个线程获取到锁,而其他获取锁的线程只能处于同步队列中等待,只有获取锁的线程释放了锁,后继的线程才能够获取锁

2.6、队列同步器的实现分析

2.6.1、同步队列

1. 简要说明

  • 同步器依赖内部的同步队列(一个FIFO双向队列)来完成同步状态的管理。 当前线程获取同步状态失败时,同步器会将当前线程以及等待状态等信息构造成为一个节点(Node)并将其加入同步队列,同时会阻塞当前线程。 当同步状态释放时会把首节点中的线程唤醒,使其再次尝试获取同步状态。
  • 同步队列中的节点(Node)用来保存获取同步状态失败的线程引用、等待状态以及前驱和后继节点
  • 节点是构成同步队列的基础,同步器拥有首节点(head)和尾节点(tail),没有成功获取同步状态的线程将会成为节点加入该队列的尾部

2. 同步器的基本结构

在这里插入图片描述

3. 节点加入到同步队列的过程

在这里插入图片描述

过程说明

同步器包含了 两个节点类型的引用一个指向头节点,而 另一个指向尾节点。当一个线程成功地获取了同步状态(或者锁), 其他线程将无法获取到同步状态, 转而被构造成为节点并加入到同步队列中,而这个加入队列的过程必须要 保证线程安全,因此同步器提供了一个基于CAS的设置尾节点的方法: compareAndSetTail

4. 首节点

在这里插入图片描述

  • 首节点获取同步状态成功的节点,首节点的线程在释放同步状态时,将会唤醒后继节点,而后继节点将会在获取同步状态成功时将自己设置为首节点
  • 设置首节点是通过获取同步状态成功的线程来完成的,由于只有一个线程能够成功获取到同步状态,因此设置头节点的方法并不需要使用CAS来保证,它只需要将首节点设置成为原首节点的后继节点并断开原首节点的next引用即可

2.6.2、独占式同步状态获取与释放

1. 独占式同步状态获取与释放的总过程

在这里插入图片描述

  • 首先调用自定义同步器实现的tryAcquire(int arg)方法,该方法保证线程安全的获取同步状态
  • 如果同步状态获取失败,则构造同步节点(独占式Node.EXCLUSIVE,同一时刻只能有一个线程成功获取同步状态)并通过addWaiter(Node node)方法将该节点加入到同步队列的尾部
  • 最后调用acquireQueued(Node node,int arg)方法,使得该节点以“死循环”的方式获取同步状态
  • 如果获取不到则阻塞节点中的线程,而被阻塞线程的唤醒主要依靠前驱节点的出队或阻塞线程被中断来实现。

2. 头节点的说明

当前线程获取同步状态并执行了相应逻辑之后,就需要释放同步状态使得后续节点能够继续获取同步状态。通过调用同步器的release(int arg)方法可以释放同步状态,该方法在释放了同步状态之后,会唤醒其后继节点(进而使后继节点重新尝试获取同步状态)。

3. 节点自旋获取同步状态的过程

在这里插入图片描述
过程说明

节点进入同步队列之后, 就进入了一个自旋的过程每个节点(或者说每个线程)都在自省地观察,当条件满足,获取到了同步状态,就可以从这个自旋过程中退出,否则依旧留在这个自旋过程中(并会阻塞节点的线程)。

4. 只有前驱节点是头节点才能够尝试获取同步状态,这是为什么?

  1. 头节点是成功获取到同步状态的节点,而头节点的线程释放了同步状态之后,将会唤醒其后继节点,后继节点的线程被唤醒后需要检查自己的前驱节点是否是头节点
  2. 维护同步队列的FIFO原则

5. 总结

  • 在获取同步状态时,同步器维护一个同步队列,获取状态失败的线程都会被加入到队列中并在队列中进行自旋
  • 移出队列(或停止自旋)的条件是前驱节点为头节点且成功获取了同步状态
  • 在释放同步状态时,同步器调用tryRelease(int arg)方法释放同步状态,然后唤醒头节点的后继节点

2.6.3、共享式同步状态获取与释放(读写锁)

1. 共享式锁与独占锁的区别

  • 共享式获取与独占式获取最主要的区别在于同一时刻能否有多个线程同时获取到同步状态
  • 共享式和独占式主要区别在于tryReleaseShared(int arg)方法必须确保同步状态(或者资源数)线程安全释放,一般是通过循环和CAS来保证的,因为释放同步状态的操作会同时来自多个线程。

在这里插入图片描述
图示说明

左半部分,共享式访问资源时,其他共享式的访问均被允许,而独占式访问被阻塞,右半部分是独占式访问资源时,同一时刻其他访问均被阻塞。

2. 成功获取到同步状态并退出自旋的条件

如果当前节点的前驱为头节点时,尝试获取同步状态,如果tryAcquireShared(int arg)方法返回值返回值大于等于0表示该次获取同步状态成功并从自旋过程中退出

2.6.4、自定义同步器实现自定义独占锁案例

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.AbstractQueuedSynchronizer;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;

public class test {
    public static void main(String[] args) {
        MyLock lock = new MyLock();

        new Thread(()->{
            lock.lock();

            try{
                System.out.println(Thread.currentThread().getName() + "  locking....");
                TimeUnit.SECONDS.sleep(1L);
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                System.out.println(Thread.currentThread().getName() + "  unlocking....");
                lock.unlock();
            }
        },"t1").start();

        new Thread(()->{
            lock.lock();

            try{
                System.out.println(Thread.currentThread().getName() + "  locking....");
            }finally {
                System.out.println(Thread.currentThread().getName() + "  unlocking....");
                lock.unlock();
            }
        },"t2").start();
    }
}

// 独占锁
class MyLock implements Lock{

    class MySync extends AbstractQueuedSynchronizer{

        @Override
        protected boolean tryAcquire(int arg) {
            if(compareAndSetState(0,1)){
                setExclusiveOwnerThread(Thread.currentThread());
                return true;
            }
            return false;
        }

        @Override
        protected boolean tryRelease(int arg) {
            setExclusiveOwnerThread(null);
            setState(0);
            return true;
        }

        @Override
        protected boolean isHeldExclusively() {
            return getState() == 1;
        }

        protected Condition newCondition(){
            return new ConditionObject();
        }
    }

    private MySync sync = new MySync();

    @Override
    public void lock() {
        sync.acquire(1);
    }

    @Override
    public void lockInterruptibly() throws InterruptedException {
        sync.acquireInterruptibly(1);
    }

    @Override
    public boolean tryLock() {
        return sync.tryAcquire(1);
    }

    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        return sync.tryAcquireNanos(1,unit.toNanos(time));
    }

    @Override
    public void unlock() {
        sync.release(1);
    }

    @Override
    public Condition newCondition() {
        return sync.newCondition();
    }
}

同步器重写的方法

  • tryAcquire()
  • tryRelease()
  • isHeldExclusively()
  • newCondition()

3、重入锁

3.1、什么是重入锁?

重入锁ReentrantLock,顾名思义,就是支持重进入的锁,它表示该锁能够支持一个线程对资源的重复加锁

3.2、重入锁例子

  1. synchronized关键字隐式的支持重进入,比如一个synchronized修饰的递归方法,在方法执行时,执行线程在获取了锁之后仍能连续多次地获得该锁。
  2. ReentrantLock虽然没能像synchronized关键字一样支持隐式的重进入,但是在调用lock()方法时,已经获取到锁的线程,能够再次调用lock()方法获取锁而不被阻塞

3.3、实现重入锁需要解决的问题

  1. 线程再次获取锁:锁需要去识别获取锁的线程是否为当前占据锁的线程,如果是,则再次成功获取。
  2. 锁的最终释放:线程重复n次获取了锁,随后在第n次释放该锁后,其他线程能够获取到该锁。(就是获得了几次就要释放几次

3.4、实现重入锁的解决办法

  1. 线程再次获取锁通过判断当前线程是否为获取锁的线程来决定获取操作是否成功,如果是获取锁的线程再次请求,则将同步状态值进行增加并返回true,表示获取同步状态成功。
  2. 锁的最终释放成功获取锁的线程再次获取锁,只是增加了同步状态值,这也就要求ReentrantLock在释放同步状态时减少同步状态值。该方法将同步状态是否为0作为最终释放的条件,当同步状态为0时,将占有线程设置为null,并返回true,表示释放成功

3.5、公平与非公平获取锁

1. 公平锁说明

公平性与否是针对获取锁而言的,如果一个锁是公平的,那么锁的获取顺序就应该符合请求的绝对时间顺序,也就是FIFO。公平的获取锁,也就是等待时间最长的线程最优先获取锁,也可以说锁获取是顺序的。

2. 公平锁的好处

公平锁能够减少“饥饿”发生的概率,等待越久的请求越是能够得到优先满足。

3. 非公平锁的说明

对于非公平锁,只要CAS设置同步状态成功,则表示当前线程获取了锁,而公平锁则不同。

4. 非公平锁的坏处

非公平性锁可能使线程“饥饿”

5. 非公平锁的好处

但极少的线程切换,保证了其更大的吞吐量

4、读写锁

4.1、什么是读写锁?

读写锁维护了一对锁,一个读锁和一个写锁,通过分离读锁和写锁,使得并发性相比一般的排他锁有了很大提升

4.2、读写锁的特点

  1. 保证写操作对读操作的可见性以及并发性的提升
  2. 读写锁能够简化读写交互场景的编程方式
  3. 读写锁的性能都会比排它锁好,因为大多数场景读是多于写的。在读多于写的情况下,读写锁能够提供比排它锁更好的并发性和吞吐量

4.3、读写锁的工作流程

当写操作开始时所有晚于写操作的读操作均会进入等待状态,只有写操作完成并进行通知之后,所有等待的读操作才能继续执行(写操作之间依靠synchronized关键进行同步),这样做的目的是使读操作能读取到正确的数据,不会出现脏读,保证写操作对读操作的可见性

4.4、读写锁的实现分析

4.4.1、 读写状态的设计

读写状态就是其同步器的同步状态。在一个整型变量上维护多种状态,就一定需要“按位切割使用”这个变量,读写锁将变量切分成了两个部分,高16位表示读,低16位表示写

在这里插入图片描述

  • S不等于0时,当写状态(S&0x0000FFFF)等于0时,则读状态(S>>>16)大于0,即读锁已被获取

4.4.2、写锁的获取与释放

1. 写锁简介

写锁一个支持重进入的排它锁

2. 写锁获取过程

  • 如果当前线程已经获取了写锁,则增加写状态
  • 如果当前线程在获取写锁时,读锁已经被获取(读状态不为0)或者该线程不是已经获取写锁的线程,则当前线程进入等待状态

3. 写锁不能获取的情况

如果存在读锁,则写锁不能被获取

原因

读写锁要确保写锁的操作对读锁可见,如果允许读锁在已被获取的情况下对写锁的获取那么正在运行的其他读线程就无法感知到当前写线程的操作。因此,只有等待其他读线程都释放了读锁,写锁才能被当前线程获取,而写锁一旦被获取,则其他读写线程的后续访问均被阻塞

4. 写锁的释放过程

写锁的释放与ReentrantLock的释放过程基本类似,每次释放均减少写状态,当写状态为0时表示写锁已被释放,从而等待的读写线程能够继续访问读写锁,同时前次写线程的修改对后续读写线程可见

4.4.3、读锁的获取与释放

1. 读锁简介

读锁是一个支持重进入的共享锁

2. 获取过程

它能够被多个线程同时获取,在没有其他写线程访问(或者写状态为0)时,读锁总会被成功地获取,而所做的也只是(线程安全的)增加读状态。如果当前线程已经获取了读锁,则增加读状态如果当前线程在获取读锁时,写锁已被其他线程获取,则进入等待状态

3. 释放过程

读锁的每次释放(线程安全的,可能有多个读线程同时释放读锁)均减少读状态,减少的值是(1<<16)。

4.4.4、锁降级

锁降级指的是写锁降级成为读锁。如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级。锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程

5、LockSupport工具

LockSupport定义了一组以park开头的方法用来阻塞当前线程,以及unpark(Thread thread)方法来唤醒一个被阻塞的线程。

在这里插入图片描述

6、Condition接口

6.1、Condition接口简介

  • 任意一个Java对象,都拥有一组监视器方法(定义在java.lang.Object上),主要包括wait()、wait(long timeout)、notify()以及notifyAll()方法,这些方法与synchronized同步关键字配合,可以实现等待/通知模式。
  • Condition接口也提供了类似Object的监视器方法,与Lock配合可以实现等待/通知模式

6.2、Object的监视器方法与Condition接口的对比

在这里插入图片描述

6.3、Condition接口的方法

  • 当调用await()方法后,当前线程会释放锁并在此等待。
  • 其他线程调用Condition对象的signal()方法,通知当前线程后,当前线程才从await()方法返回,并且在返回前已经获取了锁。

在这里插入图片描述

6.4、Condition的实现分析

6.4.1、等待队列

1. 什么是等待队列?

等待队列是一个FIFO的队列,在队列中的每个节点都包含了一个线程引用该线程就是在Condition对象上等待的线程

2. 等待队列的基本结构

在这里插入图片描述

  • 一个Condition包含一个等待队列
  • Condition拥有首节点(firstWaiter)和尾节点(lastWaiter)。
  • 当前线程调用Condition.await()方法,将会以当前线程构造节点,并将节点从尾部加入等待队列。该节点更新过程是由锁来保证线程安全的。
  • Condition拥有首尾节点的引用

3. 并发包中的Lock的同步队列和等待队列

在这里插入图片描述

而并发包中的Lock(更确切地说是同步器)拥有一个同步队列和多个等待队列

6.4.2、等待

  • 调用Condition的await()方法(或者以await开头的方法),会使当前线程进入等待队列并释放锁,同时线程状态变为等待状态。当从await()方法返回时,当前线程一定获取了Condition相关联的锁。
  • 如果从队列(同步队列和等待队列)的角度看await()方法,当调用await()方法时,相当于同步队列的首节点(获取了锁的节点)移动到Condition的等待队列中

调用await()方法后相关线程的工作流程

  • 调用await()方法的线程成功获取了锁的线程,也就是同步队列中的首节点,该方法会将当前线程构造成节点并加入等待队列中然后释放同步状态唤醒同步队列中的后继节点,然后当前线程会进入等待状态
  • 当等待队列中的节点被唤醒,则唤醒节点的线程开始尝试获取同步状态。如果不是通过其他线程调用Condition.signal()方法唤醒,而是对等待线程进行中断,则会抛出InterruptedException。
  • 同步队列的首节点并不会直接加入等待队列,而是通过addConditionWaiter()方法把当前线程构造成一个新的节点并将其加入等待队列中

6.4.3、通知

1. 当前线程加入等待队列

在这里插入图片描述
调用该方法的前置条件当前线程必须获取了锁

2. 节点从等待队列移动到同步队列的过程

在这里插入图片描述

  1. 通过调用同步器的enq(Node node)方法,等待队列中的头节点线程安全地移动到同步队列
  2. 当节点移动到同步队列后,当前线程再使用LockSupport唤醒该节点的线程

3. Condition的signalAll()

Condition的signalAll()方法,相当于对等待队列中的每个节点均执行一次signal()方法,效果就是将等待队列中所有节点全部移动到同步队列中,并唤醒每个节点的线程

相关文章
|
9天前
|
Java 开发者
Java多线程编程中的常见误区与最佳实践####
本文深入剖析了Java多线程编程中开发者常遇到的几个典型误区,如对`start()`与`run()`方法的混淆使用、忽视线程安全问题、错误处理未同步的共享变量等,并针对这些问题提出了具体的解决方案和最佳实践。通过实例代码对比,直观展示了正确与错误的实现方式,旨在帮助读者构建更加健壮、高效的多线程应用程序。 ####
|
8天前
|
Java 开发者
Java多线程编程的艺术与实践####
本文深入探讨了Java多线程编程的核心概念、应用场景及实践技巧。不同于传统的技术文档,本文以实战为导向,通过生动的实例和详尽的代码解析,引领读者领略多线程编程的魅力,掌握其在提升应用性能、优化资源利用方面的关键作用。无论你是Java初学者还是有一定经验的开发者,本文都将为你打开多线程编程的新视角。 ####
|
7天前
|
存储 安全 Java
Java多线程编程中的并发容器:深入解析与实战应用####
在本文中,我们将探讨Java多线程编程中的一个核心话题——并发容器。不同于传统单一线程环境下的数据结构,并发容器专为多线程场景设计,确保数据访问的线程安全性和高效性。我们将从基础概念出发,逐步深入到`java.util.concurrent`包下的核心并发容器实现,如`ConcurrentHashMap`、`CopyOnWriteArrayList`以及`BlockingQueue`等,通过实例代码演示其使用方法,并分析它们背后的设计原理与适用场景。无论你是Java并发编程的初学者还是希望深化理解的开发者,本文都将为你提供有价值的见解与实践指导。 --- ####
|
10天前
|
安全 Java 开发者
Java多线程编程中的常见问题与解决方案
本文深入探讨了Java多线程编程中常见的问题,包括线程安全问题、死锁、竞态条件等,并提供了相应的解决策略。文章首先介绍了多线程的基础知识,随后详细分析了每个问题的产生原因和典型场景,最后提出了实用的解决方案,旨在帮助开发者提高多线程程序的稳定性和性能。
|
5月前
|
安全 Java 程序员
Java并发编程中的锁机制与优化策略
【6月更文挑战第17天】在Java并发编程的世界中,锁是维护数据一致性和线程安全的关键。本文将深入探讨Java中的锁机制,包括内置锁、显式锁以及读写锁的原理和使用场景。我们将通过实际案例分析锁的优化策略,如减少锁粒度、使用并发容器以及避免死锁的技巧,旨在帮助开发者提升多线程程序的性能和可靠性。
|
4月前
|
存储 缓存 Java
Java面试题:解释Java中的内存屏障的作用,解释Java中的线程局部变量(ThreadLocal)的作用和使用场景,解释Java中的锁优化,并讨论乐观锁和悲观锁的区别
Java面试题:解释Java中的内存屏障的作用,解释Java中的线程局部变量(ThreadLocal)的作用和使用场景,解释Java中的锁优化,并讨论乐观锁和悲观锁的区别
53 0
|
6月前
|
安全 Java 编译器
Java并发编程中的锁优化策略
【5月更文挑战第30天】 在多线程环境下,确保数据的一致性和程序的正确性是至关重要的。Java提供了多种锁机制来管理并发,但不当使用可能导致性能瓶颈或死锁。本文将深入探讨Java中锁的优化策略,包括锁粗化、锁消除、锁降级以及读写锁的使用,以提升并发程序的性能和响应能力。通过实例分析,我们将了解如何在不同场景下选择和应用这些策略,从而在保证线程安全的同时,最小化锁带来的开销。
|
6月前
|
安全 Java 开发者
Java并发编程中的锁优化策略
【5月更文挑战第30天】 在Java并发编程领域,锁机制是实现线程同步的关键手段之一。随着JDK版本的发展,Java虚拟机(JVM)为提高性能和降低延迟,引入了多种锁优化技术。本文将深入探讨Java锁的优化策略,包括偏向锁、轻量级锁以及自旋锁等,旨在帮助开发者更好地理解和应用这些高级特性以提升应用程序的性能。
|
6月前
|
安全 Java API
Java 8中的Stream API:简介与实用指南深入理解Java并发编程:线程安全与锁优化
【5月更文挑战第29天】本文旨在介绍Java 8中引入的Stream API,这是一种用于处理集合的新方法。我们将探讨Stream API的基本概念,以及如何使用它来简化集合操作,提高代码的可读性和效率。 【5月更文挑战第29天】 在Java并发编程中,线程安全和性能优化是两个核心议题。本文将深入探讨如何通过不同的锁机制和同步策略来保证多线程环境下的数据一致性,同时避免常见的并发问题如死锁和竞态条件。文章还将介绍现代Java虚拟机(JVM)针对锁的优化技术,包括锁粗化、锁消除以及轻量级锁等概念,并指导开发者如何合理选择和使用这些技术以提升应用的性能。
|
6月前
|
缓存 Java 编译器
Java并发编程中的锁优化策略
【5月更文挑战第27天】在Java多线程开发中,锁是一种常用的同步机制,用于保证共享资源的访问顺序和一致性。然而,不当的锁使用会导致性能瓶颈甚至死锁。本文将探讨Java并发编程中的锁优化策略,包括锁粗化、锁消除、锁细化以及读写锁的使用,旨在帮助开发者提高程序的性能和可靠性。
下一篇
无影云桌面