面试官:如何实现一个乐观锁(小白都能看得懂的代码)

简介: java多线程中的锁分类多种多样,其中有一种主要的分类方式就是乐观和悲观进行划分的。这篇文章主要介绍如何自己手写一个乐观锁代码。不过文章为了保证完整性,会从基础开始介绍。

一、乐观锁概念


说是写乐观锁的概念,但是通常乐观锁和悲观锁的概念都要一块写。对比着来才更有意义。


1、悲观锁概念


悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞,直到它拿到锁。


就比如说java里面的同步机制synchronized关键字就是一个悲观锁,当一个变量或者是方法使用了synchronized修饰时,其他的线程想要拿到这个变量或者是方法的时候将就需要等到别的线程释放。


数据库里面也用到了这种悲观锁的机制。比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。这样其他的线程就不能同步操作,必须要等到他释放才可以。


2、乐观锁概念


乐观锁:总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,只在更新的时候会判断一下在此期间别人有没有去更新这个数据。


注意“在此期间”的含义是拿到数据到更新数据的这段时间。因为没有加锁,所以别的线程可能会更改。还有一点那就是乐观锁其实是不加锁的。


今天我们要实现的就是一个乐观锁机制,既然乐观锁是不加锁的,而且还要保证数据的一致性。如何来实现呢?举个例子:java中的Atomic包下的一系列类就是使用了乐观锁机制。我们挑出来一个看看官方是如何实现的,然后按照这样的实现机制我们自己就可以实现。


3、乐观锁实现案例


java并发机制中主要有三个特性需要我们去考虑,原子性、可见性和有序性。AtomicInteger的作用就是为了保证原子性。如何保证原子性呢?我们使用案例说明:

public class Test {
    //一个变量a
    private static volatile int a = 0;
    public static void main(String[] args) {
        Test test = new Test();
        Thread[] threads = new Thread[5];
        //定义5个线程,每个线程加10
        for (int i = 0; i < 5; i++) {
            threads[i] = new Thread(() -> {
                try {
                    for (int j = 0; j < 10; j++) {
                        System.out.println(a++);
                        Thread.sleep(500);
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                }
            });
            threads[i].start();
        }
    }
}

这个例子很简单:我们定义了一个变量a,初始值是0,然后使用5个线程去增加,每个线程增加10,按道理来说5个线程一共增加了50,但是运行一下就知道答案不到50,原因就在于里面那个加一操作:a++;


对于a++的操作,其实可以分解为3个步骤。


(1)从主存中读取a的值

(2)对a进行加1操作

(3)把a重新刷新到主存


比如说有的线程已经把a进行了加1操作,但是还没来得及重刷入到主存,其他的线程就重新读取了旧值。这才造成了错误。解决办法就可以使用AtomicInteger:

public class Test3 {
    //使用AtomicInteger定义a
    static AtomicInteger a = new AtomicInteger();
    public static void main(String[] args) {
        Test3 test = new Test3();
        Thread[] threads = new Thread[5];
        for (int i = 0; i < 5; i++) {
            threads[i] = new Thread(() -> {
                try {
                    for (int j = 0; j < 10; j++) {
                        //使用getAndIncrement函数进行自增操作
                        System.out.println(a.incrementAndGet());        
                        Thread.sleep(500);
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                }
            });
            threads[i].start();
        }
    }
}

现在我们使用AtomicInteger定义a,然后使用incrementAndGet进行自增操作,最后的结果就总是50了。为了什么AtomicInteger有这样的特点呢?我们来分析一下:


4、乐观锁案例分析


AtomicInteger是一个乐观锁,也就是说我们只要看一下AtomicInteger是如何实现这样的机制和原理,我们就可以找出其他乐观锁实现的一般机制。想要找出来答案我们还要从AtomicInteger的incrementAndGet方法说起。因为这个方法实现了锁一样的功能。这里使用的是jdk1.8的版本,不同的版本会有出入。

/**
* Atomically increments by one the current value.
* @return the updated value
*/
public final int incrementAndGet() {
    return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

这里我们可以看到自增操作主要是使用了unsafe的getAndAddInt方法。因为不是专门介绍AtomicInteger,所以不会对源码进行相信的分析。


(1)Unsafe:Unsafe是位于sun.misc包下的一个类,Unsafe类使Java语言拥有了类似C语言指针一样操作内存空间的能力。也就是说我们直接操作了内存空间进行了加1操作。


(2) unsafe.getAndAddInt:其内部又调用了Unsafe.compareAndSwapInt方法。这个机制叫做CAS机制,


CAS 即比较并替换,实现并发算法时常用到的一种技术。CAS操作包含三个操作数——内存位置、预期原值及新值。执行CAS操作的时候,将内存位置的值与预期原值比较,如果相匹配,那么处理器会自动将该位置值更新为新值,否则,处理器不做任何操作。

我们使用一个例子来解释相信你会更加的清楚。


比如说给你儿子订婚。你儿子就是内存位置,你原本以为你儿子是和杨贵妃在一起了,结果在订婚的时候发现儿子身边是西施。这时候该怎么办呢?你一气之下不做任何操作。如果儿子身边是你预想的杨贵妃,你一看很开心就给他们订婚了,也叫作执行操作。现在你应该明白了吧。


但是这样的CAS机制会带来一个比较常见的问题。那就是ABA问题,举个例子,你看到桌子上有100块钱,然后你去干其他事了,回来之后看到桌子上依然是100块钱,你就认为这100块没人动过,其实在你走的那段时间,别人已经拿走了100块,后来又还回来了。这就是ABA问题。


那这时候又该如何解决ABA问题呢?既然有人动了,那我们对数据加一个版本控制字段,只要有人动过这个数据,就把版本进行增加,我们看到桌子上有100块钱版本是1,回来后发现桌子上100没变,但是版本确是2,就立马明白100块有人动过。


5、乐观锁思想


OK,上面说了这么多,其实就是想说一句话那就是乐观锁可以由CAS机制+版本机制来实现。


乐观锁假设认为数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。


(1)CAS机制:当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败。CAS 有效地说明了“ 我认为位置 V 应该包含值 A;如果包含该值,则将 B 放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可“。


(2)版本机制:CAS机制保证了在更新数据的时候没有被修改为其他数据的同步机制,版本机制就保证了没有被修改过的同步机制(意思是上面的ABA问题)。

基于这个思想我们就可以实现一个乐观锁。下面我们写一下代码。这个代码在我自己电脑上亲测通过。


二、实现一个乐观锁


第一步:定义我们要操作的数据

public class Data {
    //数据版本号
    static int version = 1;
    //真实数据
    static String data = "java的架构师技术栈";
    public static int getVersion(){
        return version;
    }
    public static void updateVersion(){
        version = version + 1;
    }
}

第二步:定义一个乐观锁

public class OptimThread extends Thread { 
    public int version;
    public String data;
    //构造方法和getter、setter方法
    public void run() {
        // 1.读数据
        String text = Data.data;
        println("线程"+ getName() + ",获得的数据版本号为:" + Data.getVersion());
        println("线程"+ getName() + ",预期的数据版本号为:" + getVersion());
        System.out.println("线程"+ getName()+"读数据完成=========data = " + text);
        // 2.写数据:预期的版本号和数据版本号一致,那就更新
        if(Data.getVersion() == getVersion()){
            println("线程" + getName() + ",版本号为:" + version + ",正在操作数据");
            synchronized(OptimThread.class){
                if(Data.getVersion() == this.version){
                    Data.data = this.data;
                    Data.updateVersion();
                    System.out.println("线程" + getName() + "写数据完成=========data = " + this.data);
                    return ;
                }
            }
        }else{
             // 3. 版本号不正确的线程,需要重新读取,重新执行
            println("线程"+ getName() + ",获得的数据版本号为:" + Data.getVersion());
            println("线程"+ getName() + ",预期的版本号为:" + getVersion());
            System.err.println("线程"+ getName() + ",需要重新执行。==============");
        }  
    }
}

第三步:测试

public class Test {
    public static void main(String[] args) {
        for (int i = 1; i <= 2; i++) {
            new OptimThread(String.valueOf(i), 1, "fdd").start();
        }
    }
}

定义了两个线程,然后进行读写操作


第四步:输出结果


v2-2b8a5b4b8574cc084d9c18f57e7372c2_1440w.jpg

这个结果可以看到在读数据的时候只要发现没有变化即可,但是更新数据的时候要判断当前的版本号和预期的版本号是否一致,如果一致那就更新,如果不一致,那就说明更新失败。


OK,今天的文章先写到这。如果问题还请批评指正。

相关文章
|
6月前
|
前端开发
【面试题】如何使用ES6 ... 让代码优雅一点?
【面试题】如何使用ES6 ... 让代码优雅一点?
|
6月前
|
存储 前端开发 JavaScript
【面试题】你是如何让js 代码变得简洁的?
【面试题】你是如何让js 代码变得简洁的?
|
3月前
|
Java 编译器 C++
【Java基础面试一】、为什么Java代码可以实现一次编写、到处运行?
这篇文章解释了Java能够实现“一次编写,到处运行”的原因,主要归功于Java虚拟机(JVM),它能够在不同平台上将Java源代码编译成的字节码转换成对应平台的机器码,实现跨平台运行。
【Java基础面试一】、为什么Java代码可以实现一次编写、到处运行?
|
3月前
|
存储 缓存 Java
面试问Spring循环依赖?今天通过代码调试让你记住
该文章讨论了Spring框架中循环依赖的概念,并通过代码示例帮助读者理解这一概念。
面试问Spring循环依赖?今天通过代码调试让你记住
|
3月前
|
JavaScript 前端开发 程序员
JS小白请看!一招让你的面试成功率大大提高——规范代码
JS小白请看!一招让你的面试成功率大大提高——规范代码
|
3月前
|
算法 Java
【多线程面试题十八】、说一说Java中乐观锁和悲观锁的区别
这篇文章讨论了Java中的乐观锁和悲观锁的区别,其中悲观锁假设最坏情况并在访问数据时上锁,如通过`synchronized`或`Lock`接口实现;而乐观锁则在更新数据时检查是否被其他线程修改,适用于多读场景,并常通过CAS操作实现,如Java并发包`java.util.concurrent`中的类。
|
4月前
|
存储 缓存 Java
Java面试题:解释Java中的内存屏障的作用,解释Java中的线程局部变量(ThreadLocal)的作用和使用场景,解释Java中的锁优化,并讨论乐观锁和悲观锁的区别
Java面试题:解释Java中的内存屏障的作用,解释Java中的线程局部变量(ThreadLocal)的作用和使用场景,解释Java中的锁优化,并讨论乐观锁和悲观锁的区别
51 0
|
6月前
|
缓存 监控 算法
Python性能优化面试:代码级、架构级与系统级优化
【4月更文挑战第19天】本文探讨了Python性能优化面试的重点,包括代码级、架构级和系统级优化。代码级优化涉及时间复杂度、空间复杂度分析,使用内置数据结构和性能分析工具。易错点包括过度优化和滥用全局变量。架构级优化关注异步编程、缓存策略和分布式系统,强调合理利用异步和缓存。系统级优化则涵盖操作系统原理、Python虚拟机优化和服务器调优,需注意监控系统资源和使用编译器加速。面试者应全面理解这些层面,以提高程序性能和面试竞争力。
81 1
Python性能优化面试:代码级、架构级与系统级优化
|
5月前
|
存储 算法 Java
面试高频算法题汇总「图文解析 + 教学视频 + 范例代码」之 二分 + 哈希表 + 堆 + 优先队列 合集
面试高频算法题汇总「图文解析 + 教学视频 + 范例代码」之 二分 + 哈希表 + 堆 + 优先队列 合集
|
6月前
|
数据采集 数据挖掘 Python
最全妙不可言。写出优雅的 Python 代码的七条重要技巧,2024年最新被面试官怼了还有戏吗
最全妙不可言。写出优雅的 Python 代码的七条重要技巧,2024年最新被面试官怼了还有戏吗