本地锁和分布式锁的区别

本地所和分布式锁的理解

1. 本地锁和分布式锁的区别。

1.1. 本地锁的意义

​ 在单进程的系统中,当存在多个线程可以同时改变某个变量(可变共享变量)时,就需要对变量或代码块做同步,使其在修改这种变量时能够线性执行,以防止并发修改变量带来数据不一致或者数据污染的现象。 ​ 而为了实现多个线程在一个时刻同一个代码块只能有一个线程可执行,那么需要在某个地方做个标记,这个标记必须每个线程都能看到,当标记不存在时可以设置该标记,其余后续线程发现已经有标记了则等待拥有标记的线程结束同步代码块取消标记后再去尝试设置标记。这个标记可以理解为锁。

1.2. 分布式锁的意义

​ 如果是单机情况下(单JVM),线程之间共享内存,只要使用线程锁就可以解决并发问题。但如果是分布式情况下(多JVM),线程A和线程B很可能不是在同一JVM中,这样线程锁就无法起到作用了,这时候就要用到分布式锁来解决。

​ 分布式锁是控制分布式系统同步访问共享资源的一种方式。

2. 本地锁

2.1. 常用的本地锁有什么?

​ synchronized和lock

2.2. 本地锁锁的是什么?

​ 在非静态方法中,锁的是对象,在非静态方法中,锁的是类字节码

2.3. lock锁的原理?

​ 通过查看公平锁的源码得知在java.util.concurrent.locks的抽象类中AbstractQueuedSynchronizer,存在一个int类型的状态值,然后进行判断这个锁的状态。

​ Java ReenttrantLock通过构造函数指定该锁是否公平,默认是非公平锁,因为非公平锁的优点在于吞吐量比公平锁大,对于synchronized而言,也是一种非公平锁

2.4. volatile是什么意思?干什么用的?

2.41 volatile定义

​ 是java虚拟机提供的轻量级的同步机制。保证可见性,不保证原子性,禁止指令重排。

​ jmm

2.42 volatile的可见性

​ 由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(也叫栈空间),工作内存是每个线程的私有数据区域,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行。操作过程有三步。

  1. 首先要将变量从主内存拷贝到自己的工作内存空间。
  2. 然后对变量进行操作。
  3. 操作完成后再将变量写回主内存,不能直接操作主内存中的变量。

各个线程中的工作内存中存储着主内存中的变量副本拷贝,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,其简要访问过程如下。

img

2.43 volatile的不保证原子性

​ 原子性定义:不可分割,完整性,也就是说某个线程正在做某个具体业务时,中间不可以被加塞或者被分割,需要具体完成,要么同时成功,要么同时失败。

image-20200309174220675

2.44 如何让volatile保证原子性

  1. 最简单的方法,加sync的锁。
  2. 可以使用JUC下面的原子包装类。

2.45 volatile禁止指令重排

单线程环境里面确保最终执行结果和代码顺序的结果一致

处理器在进行重排序时,必须要考虑指令之间的数据依赖性

多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。

public void mySort() {
	int x = 11;
	int y = 12;
	x = x + 5;
	y = x * x;
}
复制代码

按照正常单线程环境,执行顺序是 1 2 3 4

但是在多线程环境下,可能出现以下的顺序:

  • 2 1 3 4
  • 1 3 2 4

上述的过程就可以当做是指令的重排,即内部执行顺序,和我们的代码顺序不一样

但是指令重排也是有限制的,即不会出现下面的顺序

  • 4 3 2 1

因为处理器在进行重排时候,必须考虑到指令之间的数据依赖性

因为步骤 4:需要依赖于 y的申明,以及x的申明,故因为存在数据依赖,无法首先执行

2.5. synchronized原理

常用的使用方法:


复制代码

java对象布局?

​ 整合对象一共16B,其中对象头有12B,还有4B是对齐的字节(64位虚拟机上对象的大小必须是8的倍数)

对象头里边存的是什么呢?

​ 对象头就是所有对象开头的公共部分。

​ 参考网站:openjdk.java.net/groups/hots…

2.5. bit和byte的区别?

​ bit意为“位”或“比特”,是计算机运算的基础;

​ byte意为“字节”,是计算机文件大小的基本计算单位;

​ byte=字节即1byte=8bits,两者换算是1:8的关系。

3. 锁的类型:

​ 从线程是否需要对资源加锁可以分为 悲观锁 和 乐观锁

​ 从锁的公平性进行区分,可以分为公平锁 和 非公平锁

​ 从多个线程能否获取同一把锁分为 共享锁 和 排他锁

​ 从资源已被锁定,线程是否阻塞可以分为 自旋锁

3.1. 悲观锁 和 乐观锁

3.11 悲观锁

​ 悲观锁是一种悲观思想,它总认为最坏的情况可能会出现,它认为数据很可能会被其他人所修改,所以悲观锁在持有数据的时候总会把资源 或者 数据 锁住,这样其他线程想要请求这个资源的时候就会阻塞,直到等到悲观锁把资源释放为止。传统的关系型数据库里边就用到了很多这种锁机制,**比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。**悲观锁的实现往往依靠数据库本身的锁功能实现。

​ Java 中的 Synchronized 和 ReentrantLock 等独占锁(排他锁)也是一种悲观锁思想的实现,因为 Synchronzied 和 ReetrantLock 不管是否持有资源,它都会尝试去加锁,生怕自己心爱的宝贝被别人拿走。

3.12 乐观锁

​ 而乐观锁的思想与悲观锁的思想相反,它总认为资源和数据不会被别人所修改,所以读取不会上锁,但是乐观锁在进行写入操作的时候会判断当前数据是否被修改过(具体如何判断我们下面再说)。乐观锁的实现方案一般来说有两种:版本号机制 和 CAS实现 。乐观锁多适用于多读的应用类型,这样可以提高吞吐量。比如在MyBaits-Plus中是支持乐观锁机制的。

乐观锁实现方式:版本号机制,参考代码

3.2. 公平锁 和 非公平锁

3.21 公平锁定义

​ 在并发环境中,多个线程需要对同一资源进行访问,同一时刻只能有一个线程能够获取到锁并进行资源访问,那么剩下的这些线程怎么办呢?这就好比食堂排队打饭的模型,最先到达食堂的人拥有最先买饭的权利,那么剩下的人就需要在第一个人后面排队,这是理想的情况,即每个人都能够买上饭。那么现实情况是,在你排队的过程中,就有个别不老实的人想走捷径,插队打饭,如果插队的这个人后面没有人制止他这种行为,他就能够顺利买上饭,如果有人制止,他就也得去队伍后面排队。

​ 根据以上总结,公平锁表示在并发环境中,每个线程在获取锁时会先查看此锁维护的等待队列,如果为空,或者当前线程是等待队列中的第一个,就占用锁,否者就会加入到等待队列中,以后按照FIFO的规则从队列中取到自己

img

3.22 公平锁使用

​ 在 Java 中,我们一般通过 ReetrantLock 来实现锁的公平性。

public class MyFairLock extends Thread {
    //创建公平锁
    private ReentrantLock lock = new ReentrantLock(true);

    public void fairLock() {
        try {
            lock.lock();
            System.out.println(Thread.currentThread().getName() + "正在持有锁");
        } finally {
            System.out.println(Thread.currentThread().getName() + "释放了锁");
            lock.unlock();
        }
    }
    public static void main(String[] args) {
        MyFairLock myFairLock = new MyFairLock();
        Runnable runnable = () -> {
            System.out.println(Thread.currentThread().getName() + "启动");
            myFairLock.fairLock();
        };
        Thread[] thread = new Thread[10];
        for (int i = 0; i < 10; i++) {
            thread[i] = new Thread(runnable);
        }
        for (int i = 0; i < 10; i++) {
            thread[i].start();
        }
    }
}
复制代码

​ 查看ReentrantLock源码可知,如果是 true 的话,那么就会创建一个 ReentrantLock 的公平锁,然后并创建一个 FairSync ,FairSync 其实是一个 Sync 的内部类,它的主要作用是同步对象以获取公平锁。

通过查看FairSync和NonfairSync的源代码对比,我们可以明显的看出公平锁与非公平锁的lock()方法唯一的区别就在于公平锁在获取同步状态时多了一个限制条件:hasQueuedPredecessors()。

​ 它主要是用来 查询是否有任何线程在等待获取锁的时间比当前线程长,也就是说每个等待线程都是在一个队列中,此方法就是判断队列中在当前线程获取锁时,是否有等待锁时间比自己还长的队列,如果当前线程之前有排队的线程,返回 true,如果当前线程位于队列的开头或队列为空,返回 false。

​ 综上,公平锁就是通过同步队列来实现多个线程按照申请锁的顺序来获取锁,从而实现公平的特性。非公平锁加锁时不考虑排队等待问题,直接尝试获取锁,所以存在后申请却先获得锁的情况。

3.22 非公平锁定义

​ 非公平锁就是一种获取锁的抢占机制,是随机获得锁的,和公平锁不一样的就是先来的不一定先得到锁,这个方式可能造成某些线程一直拿不到锁,结果也就是不公平的了。

img

3.4 独占锁和共享锁

3.4.1 独占锁定义

​ 独占锁又叫做排他锁,是指锁在同一时刻只能被一个线程拥有,其他线程想要访问资源,就会被阻塞。JDK 中 synchronized和 JUC 中 Lock 的实现类就是互斥锁。

3.4.2 共享锁定义

​ 共享锁指的是锁能够被多个线程所拥有,如果某个线程对资源加上共享锁后,则其他线程只能对资源再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。

3.4.3 实例

		ReadWriteLock lock=new ReentrantReadWriteLock();
        Lock readLock = lock.readLock();
        Lock writeLock = lock.writeLock();
复制代码

​ ReentrantReadWriteLock 有两把锁:ReadLock 和 WriteLock,也就是一个读锁一个写锁,合在一起叫做读写锁。其中读锁是共享锁,写锁是独享锁,因为读锁和写锁是分离的。所以ReentrantReadWriteLock的并发性相比一般的互斥锁有了很大提升。

3.5 自旋锁

3.5.1 自旋锁定义

​ 自旋锁是一种非阻塞锁,当一个线程尝试去获取某一把锁的时候,如果这个锁此时已经被别人获取(占用),该线程不会被挂起,那么此线程就无法获取到这把锁,该线程将会间隔一段时间后会再次尝试获取。这种采用循环加锁 -> 等待的机制被称为自旋锁(spinlock)。

3.5.2 自旋锁的优点

​ 自旋锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起再唤醒的操作的消耗。总的来说自旋锁是为了解决线程的频繁切换引起的性能损耗,所以才自旋让当前线程一直占用资源。

3.5.3 自旋锁的弊端

​ 如果长时间上锁的话,自旋锁会非常耗费性能,它阻止了其他线程的运行和调度。线程持有锁的时间越长,如果持有锁的线程发生中断情况,那么其他线程将一直保持旋转状态(反复尝试获取锁),而持有该锁的线程并不打算释放锁,这样导致的是结果是无限期推迟。

​ 解决上面这种情况一个很好的方式是给自旋锁设定一个自旋时间,等时间一到立即释放自旋锁。自旋锁的目的是占着CPU资源不进行释放,等到获取锁立即进行处理。但是如何去选择自旋时间呢?如果自旋执行时间太长,会有大量的线程处于自旋状态占用 CPU 资源,进而会影响整体系统的性能。因此自旋的周期选的额外重要!JDK在1.6 引入了适应性自旋锁,适应性自旋锁意味着自旋时间不是固定的了,而是由前一次在同一个锁上的自旋时间以及锁拥有的状态来决定,基本认为一个线程上下文切换的时间是最佳的一个时间。

3.5.4 自旋锁demo

/**
 * 手写自旋锁
 * 循环比较获取直到成功为止,没有类似于wait的阻塞
 * 通过CAS操作完成自旋锁,A线程先进来调用myLock方法自己持有锁5秒,B随后进来发现当前有线程持有锁,不是null,所以只能通过自旋等待,直到A释放锁后B随后抢到
 */
public class SpinLock {
    // 现在的泛型装的是Thread,原子引用线程
    AtomicReference<Thread> atomicReference = new AtomicReference<>();

    public void myLock() {
        // 获取当前进来的线程
        Thread thread = Thread.currentThread();
        System.out.println(Thread.currentThread().getName() + "\t come in ");

        // 开始自旋,期望值是null,更新值是当前线程,如果是null,则更新为当前线程,否者自旋
        while(!atomicReference.compareAndSet(null, thread)) {

        }
    }

    /**
     * 解锁
     */
    public void myUnLock() {

        // 获取当前进来的线程
        Thread thread = Thread.currentThread();

        // 自己用完了后,把atomicReference变成null
        atomicReference.compareAndSet(thread, null);

        System.out.println(Thread.currentThread().getName() + "\t invoked myUnlock()");
    }

    public static void main(String[] args) {

        SpinLock spinLockDemo = new SpinLock();

        // 启动t1线程,开始操作
        new Thread(() -> {
            // 开始占有锁
            spinLockDemo.myLock();
            try {
                TimeUnit.SECONDS.sleep(5);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // 开始释放锁
            spinLockDemo.myUnLock();
        }, "t1").start();


        // 让main线程暂停1秒,使得t1线程,先执行
        try {
            TimeUnit.SECONDS.sleep(5);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 1秒后,启动t2线程,开始占用这个锁
        new Thread(() -> {
            // 开始占有锁
            spinLockDemo.myLock();
            // 开始释放锁
            spinLockDemo.myUnLock();
        }, "t2").start();

    }
}
复制代码

4. 分布式锁

4.1 常用的分布式锁都有什么?

  1. 基于数据库实现分布式锁
  2. 基于缓存(Redis等)实现分布式锁
  3. 基于Zookeeper实现分布式锁

4.2 分布式锁的条件

  1. 排他性:同一时间,只能有一个客户端获取锁,其他客户端不能同事获取锁。
  2. 避免死锁:这把锁在一定的时间后需要释放,否则会产生死锁,这里面包括正常释放锁和非正常释放锁,比如即使一个客户端在持锁期间发生故障而没有释放锁也要保证后续的客户端能枷锁。
  3. 自己解锁:加锁和解锁都应该是同一个客户端去完成,不能去解别人的锁。
  4. 高可用:获取和释放锁必须高可用且优秀。

4.3 数据库分布式锁?

​ 在数据库中创建一个表,并在字段上创建唯一索引,想要执行某个方法,就使用这个方法名向表中插入数据,成功插入则获取锁,执行完成后删除对应的行数据释放锁。

4.2 Zookeeper分布式锁?

4.2.1 Zookeeper的节点类型和watch机制

zookeeper的节点类型:

PERSISTENT 持久化节点
PERSISTENT_SEQUENTIAL 顺序自动编号持久化节点,这种节点会根据当前已存在的节点数自动加 1
EPHEMERAL 临时节点, 客户端session超时这类节点就会被自动删除
EPHEMERAL_SEQUENTIAL 临时自动编号节点
复制代码

zookeeper的watch机制:

Znode发生变化(Znode本身的增加,删除,修改,以及子Znode的变化)可以通过Watch机制通知到客户端。那么要实现Watch,就必须实现org.apache.zookeeper.Watcher接口,并且将实现类的对象传入到可以Watch的方法中。Zookeeper中所有读操作(getData(),getChildren(),exists())都可以设置Watch选项。Watch事件具有one-time trigger(一次性触发)的特性,如果Watch监视的Znode有变化,那么就会通知设置该Watch的客户端。
复制代码

4.2.2 实现思路

定义锁:

​ 在通常的java并发编程中,有两种常见的方式可以用来定义锁,分别是synchronized机制和JDK5提供的ReetrantLock。然而,在zookeeper中,没有类似于这样的API可以直接使用,而是通过Zookeeper上的数据节点来表示一个锁,例如/exclusive_lock/lock节点就可以定义为一个锁。

获取锁

​ 在需要获取锁的时候,所有的客户端都会试图通过调用create()接口,在/exclusive_lock节点下创建临时子节点/exclusive_lock/lock。zookeeper会保证在所有客户端中,最终只有一个客户端能够创建成功,那么就可以认为该客户端获得了锁。同时,所有没有获得锁的客户端就需要到/exclusive_lock节点上注册一个子节点变更的Watcher监听,以便实时监听到lock节点的变更情况。

释放锁

​ 在定义锁部分,我们已经提到,/exclusive_lock/lock是一个临时节点,因此在以下两种情况下,都有可能释放锁。

1.当前获取锁的客户端发生宕机,那么Zookeeper上的这个临时节点就会被移除。

2.正常执行完业务逻辑之后,客户端就会主动将自己创建的临时节点删除

4.2.3 参考示例代码

​ 参考了ReentrantLock的设计思路,使用了模板方法设计模式。

4.3 redission分布式锁?

4.31 使用redis的原因

  1. Redis有很高的性能;
  2. Redis命令对此支持较好,实现起来比较方便

4.32 实现思路

  1. 获取锁的时候,使用setnx加锁,并使用expire命令为锁添加一个超时时间,超过该时间则自动释放锁,锁的value值为一个随机生成的UUID,通过此在释放锁的时候进行判断。
  2. 获取锁的时候还设置一个获取的超时时间,若超过这个时间则放弃获取锁。
  3. 释放锁的时候,通过UUID判断是不是该锁,若是该锁,则执行delete进行锁释放。

4.33 示例代码

也可以使用Redis的分布式锁框架--Redission代码示例

猜你喜欢

转载自juejin.im/post/5ef9ddabf265da22df3cbe83