Skip to content

java中的锁

一、介绍

Java锁机制无非就是Sychornized锁和Lock锁

Synchronized是基于JVM来保证数据同步的,而Lock则是在硬件层面,依赖特殊的CPU指令实现数据同步的

  • Synchronized,它就是一个:非公平,悲观,独享,互斥,可重入的重量级锁
  • ReentrantLock,它是一个:默认非公平但可实现公平的,悲观,独享,互斥,可重入,重量级锁。
  • ReentrantReadWriteLocK,它是一个,默认非公平但可实现公平的,悲观,写独享,读共享,读写,可重入,重量级锁。

ReentrantLock 拥有Synchronized相同的并发性和内存语义,此外还多了 锁投票,定时锁等候和中断锁等候 线程A和B都要获取对象O的锁定,假设A获取了对象O锁,B将等待A释放对O的锁定, 如果使用 synchronized ,如果A不释放,B将一直等下去,不能被中断 如果使用ReentrantLock,如果A不释放,可以使B在等待了足够长的时间以后,中断等待,而干别的事情

二、CAS

CAS简称 Compare and Swap,即比较再交换,是实现并发应用到的一种技术,cas不会阻塞线程,所以性能在某种程度上来说是比较高的,但是如果并发量非常大,自旋所消耗的资源比线程上下文切换还多。这时候就不如同步锁

底层通过Unsafe类实现原子性操作,操作包含三个操作数。1、内存地址(V),2、预期值(A),3、新值(B)

如果内存的值和预期值匹配,那么处理器会自动将该位置值更新为新值,如果在第一轮循环中,A线程获取地址里面的值(内存值)被B线程改了,那么预期值与内存值不匹配,会进行自选,到下次才有可能执行。

存在ABA问题,即ABC三个线程同时执行,假设都修改a=1的值,A修改值为3,B将值修改为2,C修改为1,假设B线程先执行完,然后到C线程执行,这时候A线程无法判断这个a=1是有没有经过修改的。

解决方法:增加版本号,如果经过修改,版本号+1,通过版本控制(于mysql的innoDB类似)

三、JUC

在 Java 5.0 提供了 java.util.concurrent(简称JUC)包,在此包中增加了在并发编程中很常用的工具类,用于定义类似于线程的自定义子系统,包括线程池,异步 IO 和轻量级任务框架;还提供了设计用于多线程上下文中的 Collection 实现等;底层采用CAS实现。

juc操作的对象里所有的属性字段必须是volatile类型的,在线程之间共享变量时保证立即可见

  • 原子更新基本类型

    • AtomicBoolean、AtomicInteger、AtomicLong :元老级的原子更新,方法几乎一模一样
    • DoubleAdder、LongAdder:jdk1.8以后提出,对Double、Long的原子更新性能进行优化提升
    • DoubleAccumulator、LongAccumulator:jdk1.8以后提出,支持自定义运算
  • 原子更新数组类型

    • AtomicIntegerArray、AtomicLongArray:int和long类型的数组进行原子性操作。
    • AtomicReferenceArray:对对象数组引用进行原子性更新
  • 原子更新属性

    类:

    • AtomicIntegerFieldUpdater、AtomicLongFieldUpdater:更新类的基本数据类型属性
    • AtomicReferenceFieldUpdater:更新类的对象属性

    使用上述类的时候,必须遵循以下原则

    • 字段必须是volatile类型的,在线程之间共享变量时保证立即可见
    • 字段的描述类型是与调用者与操作对象字段的关系一致。也就是说调用者能够直接操作对象字段,那么就可以反射进行原子操作。
    • 对于父类的字段,子类是不能直接操作的,尽管子类可以访问父类的字段。
    • 只能是实例变量,不能是类变量,也就是说不能加static关键字。
    • 只能是可修改变量,不能使final变量,因为final的语义就是不可修改。
    • 对于AtomicIntegerFieldUpdater和AtomicLongFieldUpdater只能修改int/long类型的字段,不能修改其包装类型(Integer/Long)。如果要修改包装类型就需要使用AtomicReferenceFieldUpdater。
  • 原子更新引用

    • AtomicReference:用于对引用的原子更新
    • AtomicMarkableReference:带版本戳的原子引用类型,版本戳为boolean类型。
    • AtomicStampedReference:带版本戳的原子引用类型,版本戳为int类型。

四、AQS

AQS全称为AbstractQueueSynchronizer,这个类在java.util.concurrent包下,它是一个java提高的底层同步工具类,比如:ReentrantLock、Semaphore和ReentrantReadWriteLock等锁都是基于AQS

AQS为实现依赖于先进先出 (FIFO) 等待队列的阻塞锁和相关同步器(信号量、事件,等等)提供一个框架。 此类的设计目标是成为依靠单个原子 int 值来表示状态的大多数同步器的一个有用基础。 子类必须定义更改此状态的受保护方法,并定义哪种状态对于此对象意味着被获取或被释放。 假定这些条件之后,此类中的其他方法就可以实现所有排队和阻塞机制。子类可以维护其他状态字段,但只是为了获得同步而只追踪使用 getState()、setState(int) 和 compareAndSetState(int, int) 方法来操作以原子方式更新的 int 值。 应该将子类定义为非公共内部帮助器类,可用它们来实现其封闭类的同步属性。类 AbstractQueuedSynchronizer 没有实现任何同步接口。而是定义了诸如 acquireInterruptibly(int) 之类的一些方法,在适当的时候可以通过具体的锁和相关同步器来调用它们,以实现其公共方法。

此类支持默认的独占模式和共享模式之一,或者二者都支持。处于独占模式下时,其他线程试图获取该锁将无法取得成功。在共享模式下,多个线程获取某个锁可能(但不是一定)会获得成功。此类并不“了解”这些不同,除了机械地意识到当在共享模式下成功获取某一锁时,下一个等待线程(如果存在)也必须确定自己是否可以成功获取该锁。处于不同模式下的等待线程可以共享相同的 FIFO 队列。通常,实现子类只支持其中一种模式,但两种模式都可以在(例如)ReadWriteLock 中发挥作用。只支持独占模式或者只支持共享模式的子类不必定义支持未使用模式的方法。

此类通过支持独占模式的子类定义了一个嵌套的 AbstractQueuedSynchronizer.ConditionObject 类,可以将这个类用作 Condition 实现。isHeldExclusively() 方法将报告同步对于当前线程是否是独占的;使用当前 getState() 值调用 release(int) 方法则可以完全释放此对象;如果给定保存的状态值,那么 acquire(int) 方法可以将此对象最终恢复为它以前获取的状态。没有别的 AbstractQueuedSynchronizer 方法创建这样的条件,因此,如果无法满足此约束,则不要使用它。AbstractQueuedSynchronizer.ConditionObject 的行为当然取决于其同步器实现的语义。

**简单来说:**是用一个int类型的变量表示同步状态,并提供了一系列的CAS操作来管理这个同步状态对象。

一个是state(用于计数器,类似gc回收的计数器)

一个是线程标记(当前线程是谁加的锁)

一个是阻塞队列(用于存放其它来拿锁的线程,默认非公平锁释放以后是随机获取线程的)

五、Synchronized

Synchronize逻辑上锁是对象内存堆中头部的一部分数据。JVM中的每个对象都有一个锁(或互斥锁),任何程序都可以使用它来协调对对象的多线程访问。如果任何线程想要访问该对象的实例变量,那么线程必须拥有该对象的锁(在锁内存区域设置一些标志)。所有其他的线程试图访问该对象的变量必须等到拥有该对象的锁有的线程释放锁(改变标记)。

  • 内置锁:每个java对象都可以用做一个实现同步的锁,这些锁称为内置锁。线程进入同步代码块或方法的时候会自动获得该锁,在退出同步代码块或方法时会释放该锁。获得内置锁的唯一途径就是进入这个锁的保护的同步代码块或方法。
  • 互斥锁:内置锁是一个互斥锁,这就是意味着最多只有一个线程能够获得该锁,当线程A尝试去获得线程B持有的内置锁时,线程A必须等待或者阻塞,直到线程B释放这个锁,如果B线程不释放这个锁,那么A线程将永远等待下去。

锁范围:

  • 修饰普通方法:锁住对象的实例
  • 修饰静态方法:锁住整个类
  • 修饰代码块: 锁住一个对象 synchronized (lock) 即synchronized后面括号里的内容

六、锁(lock)

lock锁是java定义的一个锁接口,其实现主要有ReentrantLock、ReentrantReadWriteLocK、WriteLocK、ReadLocK等。主要是通过乐观锁进行实现,而这些实现类是基于AQS进行实现的。

lock 获取锁与释放锁的过程,都需要程序员手动的控制 Lock用的是乐观锁方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。乐观锁实现的机制就是CAS操作,synchronized托管给jvm执行原始采用的是CPU悲观锁机制,即线程获得的是独占锁。独占锁意味着其他线程只能依靠阻塞来等待线程释放锁。

6.1 ReentrantLock

锁的状态是通过一个int值进行控制,还有一个变量记录了当前获取锁的线程,一个变量用于记录获取锁的次数

公平锁和非公平锁实现:

通过AQS实现,如果是公平锁,在获取锁的时候,如果锁已经被占有了,则加入线程队列的队尾;如果是非公平锁,在获取锁的时候,如果锁已经被占有,尝试获取一次锁,获取不到再加入队列。

6.2 ReentrantReadWriteLock

读写锁分离,加解锁的实现与ReentrantLock一致,主要在于锁的颗粒度。

特性:写写互斥、读写互斥、读读共享

读写锁分离实现:

锁状态标记是一个int类型(32位),将32位二进制分成两个16进制的数。

读锁:将数右移16位,得到读锁的个数。读锁每一个线程都维护一个计数器,与ReentrantLock不一样的是,ReentrantLock是只有一个全局维护的计数器,因为读锁是共享的,避免不同的线程直接干扰锁的状态。

写锁:与25535(2的16次方减1:2^16-1)进行与运算,得到写锁的个数。

锁降级:写线程获取写入锁后可以获取读取锁,然后释放写入锁,这样就从写入锁变成了读取锁,从而实现锁降级的特性。

缺点:锁只能降级不能升级,当线程获取到读锁以后,有写锁进来并不能立马进行写入操作,容易造成写线程饥饿

如果进行了锁降级,每次写锁以后会执行相应线程的读操作,即读到的是写的最新数据。代码如下:

package com.lcy.study.thread.lock;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

/**
 * @Description 锁降级操作
 * @Author lcy
 * @Date 2021/9/7 14:46
 */
public class LockDowngrade {

    int num = 0;

    ReadWriteLock locK = new ReentrantReadWriteLock();

    Lock readLock = locK.readLock();

    Lock writeLock = locK.writeLock();

    public void doLock(){
        writeLock.lock();
        try {
            num++;
            //锁降级
            readLock.lock();
        } finally {
            writeLock.unlock();
        }

        try {
            System.out.println("read:" + num);
        } finally {
            readLock.unlock();
        }
    }

    public static void main(String[] args){
        LockDowngrade lockDowngrade = new LockDowngrade();
        for (int i = 0; i < 5; i++) {
            new Thread(() -> lockDowngrade.doLock()).start();
        }
    }

}

6.3 StampLock

jdk8以后引入的锁,一般应用,都是读多写少,ReentrantReadWriteLock 因读写互斥,故读时阻塞写,因而性能上上不去。可能会使写线程饥饿,StampLock就解决了这个问题

原理 每次获取锁的时候,都会返回一个邮戳(stamp),相当于mysql里的version字段,释放锁的时候,再根据之前的获得的邮戳,去进行锁释放。

特点

  • 所有获取锁的方法,都返回一个邮戳(Stamp),Stamp为0表示获取失败,其余都表示成功;
  • 所有释放锁的方法,都需要一个邮戳(Stamp),这个Stamp必须是和成功获取锁时得到的Stamp一致;
  • StampedLock是不可重入的(如果一个线程已经持有了写锁,再去获取写锁的话就会造成死锁)
  • 支持锁升级跟锁降级
  • 可以乐观读也可以悲观读
  • 使用有限次自旋,增加锁获得的几率,避免上下文切换带来的开销
  • 乐观读不阻塞写操作,悲观读,阻塞写得操作

优点 相比于ReentrantReadWriteLock,吞吐量大幅提升

缺点

  • api相对复杂,容易用错
  • 内部实现相比于ReentrantReadWriteLock复杂得多

注意点 如果使用乐观读,一定要判断返回的邮戳是否是一开始获得到的,如果不是,要去获取悲观读锁,再次去读取

实例如下:

// 乐观读锁
double distanceFromOrigin() {

    // 尝试获取乐观读锁(1)
    long stamp = lock.tryOptimisticRead();
    // 将全部变量拷贝到方法体栈内(2)
    double currentX = x, currentY = y;
    // 检查在(1)获取到读锁票据后,锁有没被其他写线程排它性抢占(3)
    if (!lock.validate(stamp)) {
        // 如果被抢占则获取一个共享读锁(悲观获取)(4)
        stamp = lock.readLock();
        try {
            // 将全部变量拷贝到方法体栈内(5)
            currentX = x;
            currentY = y;
        } finally {
            // 释放共享读锁(6)
            lock.unlockRead(stamp);
        }
    }
    // 返回计算结果(7)
    return Math.sqrt(currentX * currentX + currentY * currentY);
}