• 欢迎访问 winrains 的个人网站!
  • 本网站主要从互联网整理和收集了与Java、网络安全、Linux等技术相关的文章,供学习和研究使用。如有侵权,请留言告知,谢谢!

Java 锁的深入理解

Java技术 winrains 来源:周鑫磊 8个月前 (03-31) 64次浏览

1 Lock接口

锁是用来控制多个线程访问同一个共享资源的方式,一般来说,一个锁能防止多个线程同时访问共享资源,在Lock接口出来之前,Java是通过synchronized关键字来实现锁的功能,而Java1.5之后,并发包新增了Lock接口(以及相关实现类)用来实现锁的功能,它提供了与synchronized关键字类似的同步功能,只是在使用方式上有所不同,需要显式的获取锁和释放锁。虽然缺少了隐式的便捷性,但却拥有了锁获取和释放的可操作性,可中断的获取所以及超时获取锁的的同步特性

1.1 Lock接口提供的synchronized不具备的特性:

特性 描述
尝试非阻塞式获取锁 当前线程尝试获取锁,如果这一刻没有被其他线程获取到,则成功获取并持有锁
能被中断的获取锁 与synchronized关键字不同,获取到锁的线程能够响应中断,当获取到锁的线程被中断时,中断异常会被抛出,同时锁会被释放
超时获取锁 在指定的截止时间之前获取到锁,入伙截止时间到了仍旧无法获取锁,则返回

1.2 Lock接口 API

方法名称 描述
void lock() 获取锁,调用该方法当前线程将会获取锁,当锁获取到时,从该方法返回
void lockInterruptibly() throws InterruptedException() 可中断的获取锁,和lock()方法的不同之处在于该方法可响应中断,即在锁的获取中和中断当前线程
boolean tryLock() 尝试非阻塞的获取锁,调用该方法立刻返回,如果能够获取则返回true,否则返回false
boolean tryLock(long time,TimeUnit unit) throws InterruptedException() 超时的获取锁,当前线程在以下三种情况会返回:1.当前线程在超时时间内获取到锁 2. 当前线程在超时时间内被中断 3. 超时时间结束,返回false
void unlock() 释放锁
Condition newCondition() 获取等待通知组件,该组件和当前的锁绑定,当前线程只有获得了锁,才能调用该组件的wait()方法,而调用后,当前线程释放锁

1.3 AbstractQueueSynchronized(队列同步器)

以下简称AQS
AQS是用来构建锁和其他同步组件的基础框架,它使用了一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获取线程排队工作

1.3.1 AQS API

AQS给予模板方法设计模式设计的,也就是说,使用者需要继承AQS并重写指定的方法进行实现

AQS提供如下三个方法来访问和修改同步状态:

  • getState():获取当前线程的同步状态。
  • getState(int newState):设置当前线程同步状态。
  • compareAndSetState(int expect,int update):使用CAS设置当前状态,该方法能够保证状态设置的原子性

1.3.2 AQS可重写的方法

方法名称 描述
protected boolean tryAcquire(int arg) 独占式的获取同步状态,实现该方法需要查询当前状态并判断同步状态是否符合预期,然后再进行CAS设置同步状态
protected boolean tryRelease(int arg) 独占式释放同步状态,等待获取同步状态的线程将有机会获取同步状态
protected int tryAcquireShared(int arg) 共享式获取同步状态,返回大于等于0的值,表示获取成功,反之获取失败
protected boolean tryReleaseShared(int arg) 共享式释放同步状态
protected boolean isHeldExclusively() 当前同步器是否在独占模式下被线程占用,一般该方法表示是否被当前线程所独占

1.3.3 AQS提供的模板方法

方法名称 描述
void acquire(int arg) 独占式获取同步状态,如果当前线程获取同步状态成功,则该方法返回,否则,将进入同步队列等待,该方法将会调用重写的tryAcquire(int arg)方法
void acquireInterruptibly(int arg) 与acquire(int arg)相同,但是该方法响应中断,当前线程未获取到同步状态而进入同步队列中,如果当前线程被中断,则该方法抛出异常并返回
boolean tryAcquireNanos(int arg,long nanos) 在acquireInterruptibly(int arg)基础上增加了超时限制,如果当前线程在超时时间内没有获取到同步状态,那么将会返回false,如果获取到了则返回true
void acquireShared(int arg) 共享式的获取同步状态,如果当前线程未获取到同步状态,将会进入同步队列中进行等待,与独占式的区别主要在于同一时刻可以有多个线程获取到同步状态
void acquireSharedInterruptibly(int arg) 与acquireInterruptibly(int arg)相同,该方法可响应中断
boolean tryAcquireSharedNanos(int arg,long nanos) 在acquireSharedInterruptibly(int arg)基础上增加了超时限制
boolean release(int arg) 独占式的释放同步状态,该方法会在释放同步状态之后,将同步队列中的第一个节点包含的线程唤醒
boolean releaseShared(int arg) 共享式的释放同步状态
Collection getQueueThreads() 获取等待在同步队列上的线程集合

1.3.4 独占锁和共享锁的区别

  • 独占锁,顾名思义,就是在同一时刻只能有一个线程获取到锁,而其他的线程只能在同步队列中等待,只有获取锁的线程释放了锁,后继线程才能获取到锁
  • 共享锁就是在同一时刻可以有多个线程获取锁
  • 独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享

2 常见的锁

2.1 重入锁

重入锁,也叫做递归锁,指的是同一线程外层函数获得锁之后,内层递归函数仍然有获取该锁的代码,但不受影响。

在JAVA环境下ReentrantLock和sypnchronized都是可重入锁

public class Test implements Runnable {
  public  synchronized void get() {
    System.out.println("name:" + Thread.currentThread().getName() + " get();");
    set();
  }
  public synchronized  void set() {
    System.out.println("name:" + Thread.currentThread().getName() + " set();");
  }
  @Override
  public void run() {
    get();
  }
  public static void main(String[] args) {
    Test ss = new Test();
    new Thread(ss).start();
    new Thread(ss).start();
    new Thread(ss).start();
    new Thread(ss).start();
  }
}
public class Test02 extends Thread {
  ReentrantLock lock = new ReentrantLock();
  public void get() {
    lock.lock();
    System.out.println(Thread.currentThread().getId());
    set();
    lock.unlock();
  }
  public void set() {
    lock.lock();
    System.out.println(Thread.currentThread().getId());
    lock.unlock();
  }
  @Override
  public void run() {
    get();
  }
  public static void main(String[] args) {
    Test ss = new Test();
    new Thread(ss).start();
    new Thread(ss).start();
    new Thread(ss).start();
  }
}

2.2 读写锁

相比Java中的锁(Locks in Java)里Lock实现,读写锁更复杂一些。假设你的程序中涉及到对一些共享资源的读和写操作,且写操作没有读操作那么频繁。在没有写操作的时候,两个线程同时读一个资源没有任何问题,所以应该允许多个线程能在同时读取共享资源。但是如果有一个线程想去写这些共享资源,就不应该再有其它线程对该资源进行读或写(译者注:也就是说:读-读能共存,读-写不能共存,写-写不能共存)。 这就需要一个读/写锁来解决这个问题。Java5在java.util.concurrent包中已经包含了读写锁。尽管如此,我们还是应该了解其实现背后的原理。

public class Cache {
  static Map<String, Object> map = new HashMap<String, Object>();
  static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
  static Lock r = rwl.readLock();
  static Lock w = rwl.writeLock();
  // 获取一个key对应的value
  public static final Object get(String key) {
    r.lock();
    try {
      System.out.println("正在做读的操作,key:" + key + " 开始");
      Thread.sleep(100);
      Object object = map.get(key);
      System.out.println("正在做读的操作,key:" + key + " 结束");
      System.out.println();
      return object;
    } catch (InterruptedException e) {
    } finally {
      r.unlock();
    }
    return key;
  }
  // 设置key对应的value,并返回旧有的value
  public static final Object put(String key, Object value) {
    w.lock();
    try {
      System.out.println("正在做写的操作,key:" + key + ",value:" + value + "开始.");
      Thread.sleep(100);
      Object object = map.put(key, value);
      System.out.println("正在做写的操作,key:" + key + ",value:" + value + "结束.");
      System.out.println();
      return object;
    } catch (InterruptedException e) {
    } finally {
      w.unlock();
    }
    return value;
  }
  // 清空所有的内容
  public static final void clear() {
    w.lock();
    try {
      map.clear();
    } finally {
      w.unlock();
    }
  }
  public static void main(String[] args) {
    new Thread(new Runnable() {
      @Override
      public void run() {
        for (int i = 0; i < 10; i++) {
          Cache.put(i + "", i + "");
        }
      }
    }).start();
    new Thread(new Runnable() {
      @Override
      public void run() {
        for (int i = 0; i < 10; i++) {
          Cache.get(i + "");
        }
      }
    }).start();
  }
}

2.3 乐观锁

总是认为不会产生并发问题,每次去取数据的时候总认为不会有其他线程对数据进行修改,因此不会上锁,但是在更新时会判断其他线程在这之前有没有对数据进行修改,一般会使用版本号机制或CAS操作实现,本质没有锁,效率比较高,无阻塞,无等待,重试

实现方式

  • version方式:一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加1。当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。
    核心SQL语句
update table set x=x+1, version=version+1 where id=#{id} and version=#{version};
  • CAS操作方式:即compare and swap 或者 compare and set,涉及到三个操作数,数据所在的内存值,预期值,新值。当需要更新时,判断当前内存值与之前取到的值是否相等,若相等,则用新值更新,若失败则重试,一般情况下是一个自旋操作,即不断的重试。

2.4 悲观锁

总是假设最坏的情况,每次取数据时都认为其他线程会修改,所以都会加锁(读锁、写锁、行锁等),当其他线程想要访问数据时,都需要阻塞挂起。可以依靠数据库实现,如行锁、读锁和写锁等,都是在操作之前加锁,在Java中,synchronized的思想也是悲观锁。属于重量级锁,会阻塞,会等待

2.5 synchronized

  • 优点
  1. 具有可重入性,保证原子性和可见性
  • 缺点
  1. 锁的本质是重量级锁,开销大,不能禁止重排序,产生阻塞,效率低下

2.6 分布式锁

如果想在不同的jvm中保证数据同步,使用分布式锁技术。有数据库实现、缓存redis实现、Zookeeper分布式锁

2.7 自旋锁和互斥锁的区别

  • 自旋锁(Spin lock)
    自旋锁与互斥锁有点类似,只是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是 否该自旋锁的保持者已经释放了锁,”自旋”一词就是因此而得名。其作用是为了解决某项资源的互斥使用。因为自旋锁不会引起调用者睡眠,所以自旋锁的效率远 高于互斥锁。虽然它的效率比互斥锁高,但是它也有些不足之处:
  1. 自旋锁一直占用CPU,他在未获得锁的情况下,一直运行--自旋,所以占用着CPU,如果不能在很短的时 间内获得锁,这无疑会使CPU效率降低。
  2. 在用自旋锁时有可能造成死锁,当递归调用时有可能造成死锁,调用有些其他函数也可能造成死锁,如 copy_to_user()、copy_from_user()、kmalloc()等。

因此我们要慎重使用自旋锁,自旋锁只有在内核可抢占式或SMP的情况下才真正需要,在单CPU且不可抢占式的内核下,自旋锁的操作为空操作。自旋锁适用于锁使用者保持锁时间比较短的情况下。

  • 两种锁的加锁原理
  1. 互斥锁:线程会从sleep(加锁)——>running(解锁),过程中有上下文的切换,cpu的抢占,信号的发送等开销。
  2. 自旋锁:线程一直是running(加锁——>解锁),死循环检测锁的标志位,机制不复杂。
    互斥锁属于sleep-waiting类型的锁。例如在一个双核的机器上有两个线程(线程A和线程B),它们分别运行在Core0和 Core1上。假设线程A想要通过pthread_mutex_lock操作去得到一个临界区的锁,而此时这个锁正被线程B所持有,那么线程A就会被阻塞 (blocking),Core0 会在此时进行上下文切换(Context Switch)将线程A置于等待队列中,此时Core0就可以运行其他的任务(例如另一个线程C)而不必进行忙等待。而自旋锁则不然,它属于busy-waiting类型的锁,如果线程A是使用pthread_spin_lock操作去请求锁,那么线程A就会一直在 Core0上进行忙等待并不停的进行锁请求,直到得到这个锁为止。
  • 两种锁的区别
    互斥锁的起始原始开销要高于自旋锁,但是基本是一劳永逸,临界区持锁时间的大小并不会对互斥锁的开销造成影响,而自旋锁是死循环检测,加锁全程消耗cpu,起始开销虽然低于互斥锁,但是随着持锁时间,加锁的开销是线性增长。
  • 两种锁的应用
    互斥锁用于临界区持锁时间比较长的操作,比如下面这些情况都可以考虑
  1. 临界区有IO操作
  2. 临界区代码复杂或者循环量大
  3. 临界区竞争非常激烈
  4. 单核处理器

至于自旋锁就主要用在临界区持锁时间非常短且CPU资源不紧张的情况下,自旋锁一般用于多核的服务器。

2.8 公平锁和非公平锁的区别

非公平锁:在等待锁的过程中,如果有人以新的线程妄图获取锁,都是有很大几率直接获取到锁的。白话文:公平锁是先到先得,按序进行,非公平锁就是不排队直接拿,失败再说。

3 CAS

Compare and Swap,即比较再交换。jdk5增加了并发包java.util.concurrent.*,其下面的类使用CAS算法实现了区别于synchronouse同步锁的一种乐观锁。JDK 5之前Java语言是靠synchronized关键字保证同步的,这是一种独占锁,也是是悲观锁。

  • CAS算法理解

与锁相比,使用比较交换(下文简称CAS)会使程序看起来更加复杂一些。但由于其非阻塞性,它对死锁问题天生免疫,并且,线程间的相互影响也远远比基于锁的方式要小。更为重要的是,使用无锁的方式完全没有锁竞争带来的系统开销,也没有线程间频繁调度带来的开销,因此,它要比基于锁的方式拥有更优越的性能。

  • 无锁的好处:
  1. 在高并发的情况下,它比有锁的程序拥有更好的性能;
  2. 它天生就是死锁免疫的。
  • 优点
    效率比较高,无阻塞,无等待,重试
  • 缺点:
  1. 会产生ABA问题:因为CAS需要在操作值的时候,检查值有没有发生变化,如果没有变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么CAS检查时发现它的值没有发生变化,但实际上发生了变化:A->B->A的过程
  2. 循环时间长,开销大:自旋CAS如果长时间不成功,会给CPU带来很大的执行开销
  3. 只能保证一个共享变量的原子操作:当对一个共享变量操作时,我们可以采用CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性

4 原子类

java.util.concurrent.atomic包:原子类的小工具包,支持在单个变量上解除锁的线程安全编程原子变量类相当于一种泛化的 volatile 变量,能够支持原子的和有条件的读-改-写操作。AtomicInteger 表示一个int类型的值,并提供了 get 和 set 方法,这些 Volatile 类型的int变量在读取和写入上有着相同的内存语义。它还提供了一个原子的 compareAndSet 方法(如果该方法成功执行,那么将实现与读取/写入一个 volatile 变量相同的内存效果),以及原子的添加、递增和递减等方法。AtomicInteger 表面上非常像一个扩展的 Counter 类,但在发生竞争的情况下能提供更高的可伸缩性,因为它直接利用了硬件对并发的支持。

如果同一个变量要被多个线程访问,则可以使用该包中的类
AtomicBoolean
AtomicInteger
AtomicLong
AtomicReference

作者:周鑫磊

来源:https://www.sparksys.top/archives/6


版权声明:文末如注明作者和来源,则表示本文系转载,版权为原作者所有 | 本文如有侵权,请及时联系,承诺在收到消息后第一时间删除 | 如转载本文,请注明原文链接。
喜欢 (1)