认识并发中常见的锁

Posted hssq

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了认识并发中常见的锁相关的知识,希望对你有一定的参考价值。

文章目录


1. 锁的作用

锁是确保线程安全最常见的做法

利用锁机制对共享数据做互斥同步,这样在同一时刻,只有一个线程可以执行某个方法或者某个代码块,这样就可以保证线程安全

2. 乐观锁和悲观锁

1)乐观锁

乐观锁在操作数据时非常乐观,认为别人不会同时修改数据。因此乐观锁不会上锁,只是在执行更新的时候判断一下是否发生冲突,如果发生冲突则放弃操作,否则执行操作

2)悲观锁

悲观锁在操作数据时比较悲观,认为别人会同时修改数据,因此在操作数据之前先上锁,直到操作完成后释放锁,期间其他人不能修改数据

3)乐观锁和悲观锁在 Java 中的典型实现

  • 悲观锁在 Java 中的应用就是通过使用 synchronized 和 Lock 加锁来进行互斥同步

  • 乐观锁的一个重要功能就是检测出数据是否发生访问冲突,一般使用以下两种方法实现此功能:

    1. 引入数据版本号
    2. CAS机制

4)数据版本机制

为每段数据添加一个版本号,线程从主存中读取到数据时会将数据版本号一并读出,在对数据进行修改完成之后,会将自身数据的版本号 +1,在提交到主存之前先对比自身数据版本和主存数据版本,当满足 提交的数据版本大于当前主存中的数据版本时才能执行数据更新,否则就说明发生了冲突,认为此次操作失败

3. CAS 机制

1)什么是 CAS

CAS 全称 Compare and swap,字面意思就是:比较并交换

CAS 包括三个操作数:内存中的原数据 V,旧的预期值 A,需要修改的新值 B

具体操作如下:

  1. 比较 A 与 V 是否相等(比较)
  2. 如果比较相等,将 B 写入 V(交换)
  3. 返回操作是否成功

当多个线程同时对某资源进行 CAS 操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败的信号

2)CAS 的 ABA 问题

什么是 ABA 问题

假设存在两个线程 t1 和 t2,有一个共享变量 num,初始值为1

线程 t1 想把1变成2,但在这之前,线程 t2 将 1 变成 2,再从 2 变回 1

到 t1 执行操作时,CAS 判断原数据等于预期值,就认为没有被修改过,所以 t1 会继续后边的操作

ABA 问题引来的 BUG

当数据类型为基本数据类型时,那么此时对结果不会有影响

当数据类型是一个引用类型时,那么就可能会产生影响,因为其他线程可能更改了引用的对象中的东西,但是引用还是那个引用。就比如:我的手机被别人借去用了几天,又还了回来,手机还是那个手机,但里面的东西可能就和之前不一样了

ABA 问题的解决方法

在 CAS 机制中加入数据版本机制,给要修改的值加上数据版本号,在 CAS 比较当前值和旧值是否相等的同时,还要比较数据版本是否相同

4. 读写锁

读写锁中拥有两把锁,一个读锁,一个写锁,在执行加锁操作时需要额外表明需要读锁还是写锁。

特点:

  • 同一时刻允许多个持有读锁的线程对共享资源进行读操作
  • 同一时刻只允许一个持有写锁的线程对共享资源进行写操作
  • 当当前线程持有共享资源的读锁时,同一时刻其他持有写锁的线程会被阻塞

读写锁更适合于 “ 频繁读,不频繁写 ” 的场景中

1)Java 标准库中提供的读写锁

Java 标准库中提供了 ReentrantReadWriteLock 类,来实现读写锁

  • ReentrantReadWriteLock.ReadLock 类表示一个读锁,这个类提供了 lock / unlock 方法进行加解锁
  • ReentrantReadWriteLock.WriteLock 类表示一个写锁,这个类提供了 lock / unlock 方法进行加解锁

5. 偏向锁、轻量级锁和重量级锁

1)偏向锁

偏向锁不是真正的 “ 加锁 ”,只是给对象头中做了一个标记,记录这个锁属于哪个线程,如果后续没有其他线程来竞争锁,那么就不用进行同步操作了,避免了加锁解锁的开销

2)轻量级锁

在锁是偏向锁时,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他锁会通过自旋的方式尝试获取锁,不会阻塞,性能提高

3)重量级锁

在锁是轻量级锁的时候,另一个线程虽然自旋,但自旋不会一直持续下去,当自旋一定次数还没有获取到锁,就会进入阻塞,轻量级锁就会膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低

6. 自旋锁

按之前的方式,线程在抢锁失败后会进入阻塞状态,放弃 CPU,需要过很久才能再次被调度

实际上,大部分情况下,虽然抢锁失败,但是过不了多久,锁就会被释放,没必要放弃 CPU,这个时候就可以使用自旋锁来处理这样的问题

工作原理:

如果获取锁失败,立即再尝试获取锁,无限循环,直到获取到锁为止。第一次获取锁失败,第二次的尝试会在极端的时间内到来

一旦锁被其他线程释放,就能在第一时间获取到锁

7. 公平锁和非公平锁

假设有 A、B、C 三个线程,A 先尝试加锁,加锁成功,然后 B 尝试加锁,加锁失败,阻塞等待;然后 C 尝试加锁,加锁失败,阻塞等待

当 A 释放锁之后,谁先获取到锁呢?

**公平锁:**遵守 “ 先来后到 ” 的原则,B 比 C 先来,A 释放锁之后,B 就能先于 C 获取到锁

**非公平锁:**不遵守 “ 先来后到 ” 的原则,B 和 C 都有可能获取到锁

一张简图让你了解公平锁和非公平锁

注意:

  • 操作系统内部的线程调度是随机的,如果不做任何限制,锁就是非公平锁,如果要实现公平锁,就需要额外的数据结构,来记录县城们的先后顺序
  • 公平锁和非公平锁没有好坏之分,关键看使用场景

Java线程并发中常见的锁

随着互联网的蓬勃发展,越来越多的互联网企业面临着用户量膨胀而带来的并发安全问题。本文着重介绍了在java并发中常见的几种锁机制。

 

 

1.偏向锁

 

偏向锁是JDK1.6提出来的一种锁优化的机制。其核心的思想是,如果程序没有竞争,则取消之前已经取得锁的线程同步操作。也就是说,若某一锁被线程获取后,便进入偏向模式,当线程再次请求这个锁时,就无需再进行相关的同步操作了,从而节约了操作时间,如果在此之间有其他的线程进行了锁请求,则锁退出偏向模式。在JVM中使用-XX:+UseBiasedLocking

package jvmProject;

import java.util.List;
import java.util.Vector;

public class Biased {

    public static List<Integer> numberList = new Vector<Integer>();
    public static void main(String[] args) {
        long begin = System.currentTimeMillis();
        int count = 0;
        int startnum = 0;
        while(count<10000000){
            numberList.add(startnum);
            startnum+=2;
            count++;        
        }
        long end = System.currentTimeMillis();
        System.out.println(end-begin);
    }
}

 

初始化一个Vector,往里面添加10000000个Integer对象,然后输出时间差。以此来测试偏向锁的性能。至于为什么要使用Vector而不使用ArrayList呢?

因为ArrayList是线程不安全的,Vector是线程安全的。这样说可能还不够具体,可以翻看一下源码吧。

Vector中的几乎所有操作是带有sychronized的,而ArrayList是没有的,所以Vector是线程安全的。

接下来我们来测试一下,开启偏向锁和不开启偏向锁对程序性能的影响有多大。

 

 

配置JVM启动(开启偏向锁)参数为:

 

 

 

配置JVM启动(关闭偏向锁)参数为:

 

 

Perfect!开启偏向锁的程序运行时间明显较短,开启偏向锁比不开启偏向锁,在单个线程中操作一个对象的同步方法,是有一定的优势的。其实也可以这样理解,当只有一个线程操作带有同步方法的Vector对象的时候,此时对Vector的操作就转变成了对ArrayList的操作。

 

偏向锁在锁竞争激烈的场合没有太强的优化效果,因为大量的竞争会导致持有锁的线程不停地切换,锁也很难保持在偏向模式,此时,使用偏向锁不仅得不到性能的优化,反而有可能降低系统的性能,因此,在激烈竞争的场合,可以尝试使用

-XX:-UseBiastedLocking参数禁用偏向锁。

 

2.轻量级锁

 

如果偏向锁失败,Java虚拟机就会让线程申请轻量级锁,轻量级锁在虚拟机内部,使用一个成为BasicObjectLock的对象实现的,这个对象内部由一个BasicLock对象和一个持有该锁的Java对象指针组成。BasicObjectLock对象放置在Java栈帧中。在BasicLock对象内部还维护着displaced_header字段,用于备份对象头部的Mark Word.

当一个线程持有一个对象的锁的时候,对象头部Mark Word信息如下

[ptr                       |00] locked  

末尾的两位比特为00,整个Mark Word为指向BasicLock对象的指针。由于BasicObjectLock对象在线程栈中,因此该指针必然指向持有该锁的线程栈空间。当需要判断一个线程是否持有该对象时,只需要简单地判断对象头的指针是否在当前线程的栈地址范围即可。同时,BasicLock对象的displaced_header,备份了原对象的Mark word内容,BasicObjectLock对象的obj字段则指向持有锁的对象头部。

 

3.重量级锁

 

当轻量级锁失败,虚拟机就会使用重量级锁。在使用重量级锁的时,对象的Mark Word如下:

[ptr                     |10]  monitor

 

重量级锁在操作过程中,线程可能会被操作系统层面挂起,如果是这样,线程间的切换和调用成本就会大大提高。

 

4.自旋锁

      自旋锁可以使线程在没有取得锁的时候,不被挂起,而转去执行一个空循环,(即所谓的自旋,就是自己执行空循环),若在若干个空循环后,线程如果可以获得锁,则继续执行。若线程依然不能获得锁,才会被挂起。

使用自旋锁后,线程被挂起的几率相对减少,线程执行的连贯性相对加强。因此,对于那些锁竞争不是很激烈,锁占用时间很短的并发线程,具有一定的积极意义,但对于锁竞争激烈,单线程锁占用很长时间的并发程序,自旋锁在自旋等待后,往往毅然无法获得对应的锁,不仅仅白白浪费了CPU时间,最终还是免不了被挂起的操作 ,反而浪费了系统的资源。

在JDK1.6中,Java虚拟机提供-XX:+UseSpinning参数来开启自旋锁,使用-XX:PreBlockSpin参数来设置自旋锁等待的次数。

在JDK1.7开始,自旋锁的参数被取消,虚拟机不再支持由用户配置自旋锁,自旋锁总是会执行,自旋锁次数也由虚拟机自动调整。

以上是关于认识并发中常见的锁的主要内容,如果未能解决你的问题,请参考以下文章

Java线程并发中常见的锁--自旋锁 偏向锁

Java 常见的锁

Java高并发编程实战1,那些年学过的锁

java并发编程常见锁类型

并发王者课-青铜5:一探究竟-如何从synchronized理解Java对象头中的锁

java 多线程中的锁的类别及使用