Java 锁机制了解一下

Java
261
0
0
2023-09-13

在 多线程 环境下,程序往往会出现一些线程安全问题,为此,Java提供了一些线程的同步机制来解决安全问题,比如: synchronized 锁和Lock锁都能解决线程安全问题。

悲观锁和乐观锁

我们可以将锁大体分为两类:

  • 悲观锁
  • 乐观锁

顾名思义,悲观锁总是假设最坏的情况,每次获取数据的时候都认为别的线程会修改,所以每次在拿数据的时候都会上锁,这样其它线程想要修改这个数据的时候都会被阻塞直到获取锁。比如My sql 数据库中的表锁、行锁、读锁、写锁等,Java中的synchronized和ReentrantLock等。

而 乐观锁 总是假设最好的情况,每次获取数据的时候都认为别的线程不会修改,所以并不会上锁,但是在修改数据的时候需要判断一下在此期间有没有别的线程修改过数据,如果没有修改过则正常修改,如果修改过则这次修改就是失败的。常见的乐观锁有版本号控制、CAS算法等。

悲观锁应用

案例如下:

 public class LockDemo {

     static  int count = 0;

    public static  void  main(String[] args) throws Interrupted Exception  {
        List<Thread> threadList = new ArrayList<>();
        for (int i =; i < 50; i++) {
             thread  thread = new Thread(() -> {
                for (int j =; j < 1000; ++j) {
                    count++;
                }
            });
            thread.start();
            threadList.add(thread);
        }
        // 等待所有线程执行完毕
        for (Thread thread : threadList) {
            thread.join();
        }
        System.out.println(count);
    }
} 

在该程序中一共开启了50个线程,并在线程中对共享变量count进行++操作,所以如果不发生 线程 安全问题,最终的结果应该是50000,但该程序中一定存在线程安全问题,运行结果为:

48634

若想解决线程安全问题,可以使用synchronized关键字:

 public class  lock Demo {

    static int count =;

    public static void main(String[] args) throws InterruptedException {
        List<Thread> threadList = new ArrayList<>();
        for (int i =; i < 50; i++) {
            Thread thread = new Thread(() -> {
                // 使用synchronized关键字解决线程安全问题
                synchronized (LockDemo.class) {
                    for (int j =; j < 1000; ++j) {
                        count++;
                    }
                }
            });
            thread.start();
            threadList.add(thread);
        }
        for (Thread thread : threadList) {
            thread.join();
        }
        System.out.println(count);
    }
} 

将修改count变量的操作使用synchronized关键字包裹起来,这样当某个线程在进行++操作时,别的线程是无法同时进行++的,只能等待前一个线程执行完1000次后才能继续执行,这样便能保证最终的结果为50000。

使用ReentrantLock也能够解决线程安全问题:

 public class LockDemo {

    static int count =;

    public static void main(String[] args) throws InterruptedException {
        List<Thread> threadList = new ArrayList<>();
        Lock lock = new ReentrantLock();
        for (int i =; i < 50; i++) {
            Thread thread = new Thread(() -> {
                // 使用ReentrantLock关键字解决线程安全问题
                lock.lock();
                try {
                    for (int j =; j < 1000; ++j) {
                        count++;
                    }
                } finally {
                    lock.unlock();
                }
        //java学习交流:  进入可领取学习资源及对十年开发经验大佬提问,免费解答!
            });
            thread.start();
            threadList.add(thread);
        }
        for (Thread thread : threadList) {
            thread.join();
        }
        System.out.println(count);
    }
} 

这两种锁机制都是悲观锁的具体实现,不管其它线程是否会同时修改,它都直接上锁,保证了 原子操作 。

乐观锁应用

由于线程的调度是极其耗费操作系统资源的,所以,我们应该尽量避免线程在不断阻塞和唤醒中切换,由此产生了乐观锁。

在数据库表中,我们往往会设置一个version字段,这就是乐观锁的体现,假设某个数据表的数据内容如下:

+—-+——+———-+ ——- +

| id | name | password | version |

+—-+——+———-+ ——- +

| 1 | zs | 123456 | 1 |

+—-+——+———-+ ——- +

它是如何避免线程安全问题的呢?

假设此时有两个线程A、B想要修改这条数据,它们会执行如下的sql语句:

 select version from e_user where name = 'zs';

update e_user set password = 'admin',version = version + where name = 'zs' and version = 1; 

首先两个线程均查询出zs用户的版本号为1,然后线程A先执行了更新操作,此时将用户的密码修改为了admin,并将版本号加1,接着线程B执行更新操作,此时版本号已经为2了,所以更新肯定是失败的,由此,线程B就失败了,它只能重新去获取版本号再进行更新,这就是乐观锁,我们并没有对程序和数据库进行任何的加锁操作,但它仍然能够保证线程安全。

CAS

仍然以最开始做加法的程序为例,在Java中,我们还可以采用一种特殊的方式来实现它:

 public class LockDemo {

    static AtomicInteger count = new AtomicInteger();

    public static void main(String[] args) throws InterruptedException {
        List<Thread> threadList = new ArrayList<>();
        for (int i =; i < 50; i++) {
            Thread thread = new Thread(() -> {
                for (int j =; j < 1000; ++j) {
                    // 使用AtomicInteger解决线程安全问题
                    count.incrementAndGet();
                }
            });
            thread.start();
            threadList.add(thread);
        }
        for (Thread thread : threadList) {
            thread.join();
        }
        System.out.println(count);
    }
} 

为何使用AtomicInteger类就能够解决线程安全问题呢?

我们来查看一下源码:

 public final int incrementAndGet() {
    return unsafe.getAndAddInt(this, valueOffset,) + 1;
} 

当count调用incrementAndGet()方法时,实际上调用的是UnSafe类的getAndAddInt()方法:

 public final int getAndAddInt(Object var, long var2, int var4) {
    int var;
    do {
        var = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var, var2, var5, var5 + var4));

    return var;
} 

getAndAddInt()方法中有一个循环,关键的代码就在这里,我们假设线程A此时进入了该方法,此时var1即为AtomicInteger对象(初始值为0),var2的值为12(这是一个内存偏移量,我们可以不用关心),var4的值为1(准备对count进行加1操作)。

首先通过AtomicInteger对象和内存偏移量即可得到主存中的数据值:

 var = this.getIntVolatile(var1, var2); 

获取到var5的值为0,然后程序会进行判断:

 !this.compareAndSwapInt(var, var2, var5, var5 + var4) 

compareAndSwapInt()是一个本地方法,它的作用是比较并交换,即:判断var1的值与主存中取出的var5的值是否相同,此时肯定是相同的,所以会将var5+var4的值赋值给var1,并返回true,对true取反为false,所以循环就结束了,最终方法返回1。

这是一切正常的运行流程,然而当发生并发时,处理情况就不太一样了,假设此时线程A执行到了getAndAddInt()方法:

 public final int getAndAddInt(Object var, long var2, int var4) {
    int var;
    do {
        var = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var, var2, var5, var5 + var4));

    return var;
} 

线程A此时获取到var1的值为0(var1即为共享变量AtomicInteger),当线程A正准备执行下去时,线程B抢先执行了,线程B此时获取到var1的值为0,var5的值为0,比较成功,此时var1的值就变为1;这时候轮到线程A执行了,它获取var5的值为1,此时var1的值不等于var5的值,此次加1操作就会失败,并重新进入循环,此时var1的值已经发生了变化,此时重新获取var5的值也为1,比较成功,所以将var1的值加1变为2,若是在获取var5之前别的线程又修改了主存中var1的值,则本次操作又会失败,程序重新进入循环。

这就是利用自旋的方式来实现一个乐观锁,因为它没有加锁,所以省下了线程调度的资源,但也要避免程序一直自旋的情况发生。

手写一个 自旋锁

 public class LockDemo {

     private  AtomicReference<Thread> atomicReference = new AtomicReference<>();

    public void lock() {
        // 获取当前线程对象
        Thread thread = Thread.currentThread();
        // 自旋等待
        while (!atomicReference.compareAndSet(null, thread)) {
        }
    }

    public void unlock() {
        // 获取当前线程对象
        Thread thread = Thread.currentThread();
        atomicReference.compareAndSet(thread, null);
    }
    //java学习交流:  进入可领取学习资源及对十年开发经验大佬提问,免费解答!

    static int count =;

    public static void main(String[] args) throws InterruptedException {
        LockDemo lockDemo = new LockDemo();
        List<Thread> threadList = new ArrayList<>();
        for (int i =; i < 50; i++) {
            Thread thread = new Thread(() -> {
                lockDemo.lock();
                for (int j =; j < 1000; j++) {
                    count++;
                }
                lockDemo.unlock();
            });
            thread.start();
            threadList.add(thread);
        }
        // 等待线程执行完毕
        for (Thread thread : threadList) {
            thread.join();
        }
        System.out.println(count);
    }
} 

使用CAS的原理可以轻松地实现一个自旋锁,首先,AtomicReference中的初始值一定为null,所以第一个线程在调用lock()方法后会成功将当前线程的对象放入AtomicReference,此时若是别的线程调用lock()方法,会因为该线程对象与AtomicReference中的对象不同而陷入循环的等待中,直到第一个线程执行完++操作,调用了unlock()方法,该线程才会将AtomicReference值置为null,此时别的线程就可以跳出循环了。

通过CAS机制,我们能够在不添加锁的情况下模拟出加锁的效果,但它的缺点也是显而易见的:

  • 循环等待占用CPU资源
  • 只能保证一个变量的原子操作
  • 会产生ABA问题