JAVA面试考点——java锁机制

家电修理 2023-07-16 19:16www.caominkang.com电器维修

目录

1. 什么是锁

 2. java锁机制

3. synchronize原理

4. 锁的四种状态, synchronized中的锁如何变化

偏向锁

轻量级锁

自旋锁(补充说明)

重量级锁

5、无锁编程

互斥锁

CAS pare and sap

乐观锁 Optimistic Concurrency Control

java中如何利用CAS特性进行无锁编程

AQS(AbstractQueuedSynchronizer.class)

AQS成员变量

AQS独占模式源码分析

补充

JAVA中断


 1. 什么是锁

在并发环境下。多个线程会对同一个资源进行争抢,可能会导致数据不一致问题。可以使用锁机制,通过一种抽象的锁来对资源进行锁定。

 2. java锁机制

java中,每个对象有一把锁,这把锁存放在对象头中。

java对象包含了三个部分

对象头(存放对象运行时信息)、实例数据、对齐填充字节(为满足java对象的大小必须是8比特的倍数这一条件而设计的)

 对象头中包含两部分mark ork(32bit)

3. synchronize原理

 synchronized编译后生成monitor enter和monitor exit两个字节码指令进行线程同步。

使用javac和javap对java代码进行编译和反编译

 

 这样就可以看到可读性较高的字节码,如下

 这里的monitor常被理解为监视器或者管程

synchronized可能存在性能问题,因为synchronized编译后生成monitor enter和monitor exit两个字节码指令,monitor依赖于操作系统的mutex lock来实现的。

java线程实际上是对操作系统线程的映射,所以每次挂起或者唤醒一个线程,都要切换操作系统的内核态,这种操作是比较重量级的,在一些情况下,甚至切换时间超出了任务的执行时间。

这样的话,使用 synchronized会对性能产生严重影响。

java6开始,synchronized 进行了优化,引入了偏向锁、轻量级锁。

,锁有四种状态(锁只能升级,不能降级)

无锁、偏向锁、轻量级锁、重量级锁

4. 锁的四种状态, synchronized中的锁如何变化

偏向锁

顾名思义,就是让对象认识线程,只为一个线程提供数据。

 

 如果对象发现有多个线程在竞争数据,那么锁会升级为轻量级锁。

轻量级锁

一旦自旋等待的线程数超过一个,那么轻量级锁将会升级为重量级锁。

自旋锁(补充说明)

可以理解为一种轮询。线程在不断循环验证目标对象的锁是否被释放,如果释放则获取锁,否则则进行下一轮循环。

这种方式区别于被操作系统挂起阻塞,因为如果对象的锁很快就会被释放的话,自选就不需要进行系统中断和现场恢复,所以他的效率更高。

自旋相当于cpu空转,如果长时间自旋将会浪费cpu资源,于是出现了“适应性自旋”的优化,即自旋时间不再固定,而是由上一次在同一个锁上的自旋时间以及锁状态,这两个条件来决定自旋时间。

重量级锁

 此时需要使用monitor来对资源进行控制。

5、无锁编程

用过AQS吗,有具体的例子可以说说吗?

了解CAS吗,谈一谈你对CAS的理解吧?

看过JUC的源码吗?聊一聊具体的实现吧?

假设现在有多个线程想要操作同一个资源对象,很多人的第一反应就是使用互斥锁

互斥锁

互斥锁的同步方式是悲观的,即操作系统认为,如果不严格控制线程调用,就一定会产生异常。互斥锁将会将资源锁定,只供一个线程调用。

互斥锁并不是万能的,比如一些情况下,大部分都是读操作,此时没有必要在每次读取时都锁定资源。

或者一些情况下,同步代码块执行的耗时远远小于线程切换的耗时,此时也不适合使用互斥锁。

CAS pare and sap

当资源可用时,有两个线程认为资源当前状态是可用时,他们各会产生两个值

  • old value代表之前读到的资源对象的状态值
  • ne value 代表想要资源对象的状态值

 如下图所示,资源的状态为 0 ,此时两个线程A和B都电脑维修网希望把资源的状态改为1,然后占用该资源。

假设A线程率先获得了时间片,他将old value与资源的状态进行pare,发现一致,于是将资源的状态值设置为ne value;

而B线程落后了一步,此时资源的状态值已被修改,此时B线程在pare的时候发现与自己预期的old value不一致,所以放弃sap操作。

 在现实生活中,我们通常会让B线程进行自旋等待,自旋就是使其不断的重试CAS操作,通常会配置自旋次数来方式死循环。

 疑问

1、CAS分为pare和sap两步操作,多线程下难以保证一致性?

CAS必须是原子性的,即比较old value和更新ne value这两步必须在只能由一条线程进行操作。

2、如何实现CAS的原子性?

各种不同架构的CPU都提供了指令级别的CAS原子操作

乐观锁 Optimistic Concurrency Control

通过CAS来实现同步的工具,由于不会锁定资源,并且总是乐观的认为资源没有被修改过,并且每次都会自己主动尝试去pare状态值,这种同步机制被称为乐观锁。

乐观锁使用了无锁的同步机制,实际上没有用到锁

java中如何利用CAS特性进行无锁编程

AtomicInteger类底层使用CAS实现同步计数器。

下面代码中使用AtomicInteger实现三个线程累加到1000,而不产生线程安全问题。

public class Main {
 
 static AtomicInteger num = ne AtomicInteger(0);

 public static void main(String[] args) {
  for (int i = 0; i < 3; i++) {
   Thread t = ne Thread(ne Runnable() {
    @Override
    public void run() {
     hile (num.get() < 1000) {
      System.out.println("thread name:" + 
          Thread.currentThread().getName() + ":" + 
          num.incrementAndGet());
     }
    }

   });
   t.start();
  }
 }
}

我们进入源码来看下AtomicInteger 是如何实现无锁同步的。

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

 
 @HotSpotIntrinsicCandidate
 public final int getAndAddInt(Object o, long offset, int delta) {
  int v;
  do {
   v = getIntVolatile(o, offset);
  } hile (!eakCompareAndSetInt(o, offset, v, v + delta));
  return v;
 }

 @HotSpotIntrinsicCandidate
 public final boolean eakCompareAndSetInt(Object o, long offset,
             int expected,
             int x) {
  return pareAndSetInt(o, offset, expected, x);
 }

 
 @HotSpotIntrinsicCandidate
 public final native boolean pareAndSetInt(Object o, long offset,
             int expected,
             int x);

CAS的具体实现是在Unsafe.java中。Unsafe.java主要用于执行一些底层的,与平台相关的方法。

上面代码中 pareAndSetInt 方法使用了native修饰符,说明这个方法是一个本地方法,与具体的平台实现相关。

自旋的次数可以通过启动参数进行配置,默认值为10。

AQS(AbstractQueuedSynchronizer.class)

多线程中竞争的资源以对象的形式进行封装,而CAS只能原始的修改内存上的一个值。该如何利用CAS去同步对象,就需要进一步的抽象。

JAVA是如何利用CAS进行对象同步的呢?

此时可以先思考一下如何设计一个同步管理框架

1. 通用性,下层实现透明的同步机制,与上层业务解耦

2. 利用CAS,原子地修改共享标记位

3. 等待队列

AQS成员变量

这里的状态字段state没有设置成boolean类型,是因为线程或是锁的两种模式为独占和共享

  • 独占模式 一旦被占用,其他线程都不能占用。
  • 共享模式 一旦被占用, 其他共享模式下的线程能占用。

所以state表示的是线程占用的数量,使用了int。

 
 private transient volatile Node head;

 
 private transient volatile Node tail;

 
 private volatile int state;

当有线程没有获取到资源时,有可能会选择排队,这里使用了链表对等待的线程进行排队,队列使用FIFO的思想。数据类型为Node。上面的head和tail属性表示队列的头和尾。

  队列中的节点有两种模式独占和共享

AQS独占模式源码分析

对于Node对象,他主要存储了如下信息

  • 1. 线程对象
  • 2. 节点在队列里的等待状态(aitStatus)
  • 3. 前后指针(prev、next)等信息

源码如下所示

 static final class Node {
  
  static final Node SHARED = ne Node();
  
  static final Node EXCLUSIVE = null;

  
  static final int CANCELLED =  1;
  
  static final int SIGNAL = -1;
  
  static final int ConDITION = -2;
  
  static final int PROPAGATE = -3;

  
  volatile int aitStatus;

  
  volatile Node prev;

  
  volatile Node next;

  
  volatile Thread thread;

  
  Node nextWaiter;

  
  final boolean isShared() {
   return nextWaiter == SHARED;
  }

  
  final Node predecessor() {
   Node p = prev;
   if (p == null)
    thro ne NullPointerException();
   else
    return p;
  }

  
  Node() {}

  
  Node(Node nextWaiter) {
   this.nextWaiter = nextWaiter;
   THREAD.set(this, Thread.currentThread());
  }

  
  Node(int aitStatus) {
   WAITSTATUS.set(this, aitStatus);
   THREAD.set(this, Thread.currentThread());
  }

  
  final boolean pareAndSetWaitStatus(int expect, int update) {
   return WAITSTATUS.pareAndSet(this, expect, update);
  }

  
  final boolean pareAndSetNext(Node expect, Node update) {
   return NEXT.pareAndSet(this, expect, update);
  }

  final void setPrevRelaxed(Node p) {
   PREV.set(this, p);
  }

  // VarHandle mechanics
  private static final VarHandle NEXT;
  private static final VarHandle PREV;
  private static final VarHandle THREAD;
  private static final VarHandle WAITSTATUS;
  static {
   try {
    MethodHandles.Lookup l = MethodHandles.lookup();
    NEXT = l.findVarHandle(Node.class, "next", Node.class);
    PREV = l.findVarHandle(Node.class, "prev", Node.class);
    THREAD = l.findVarHandle(Node.class, "thread", Thread.class);
    WAITSTATUS = l.findVarHandle(Node.class, "aitStatus", int.class);
   } catch (ReflectiveOperationException e) {
    thro ne ExceptionInInitializerError(e);
   }
  }
 }

线程在获取锁时可能会有两种行为

  • 1. 尝试获取锁,然后立即返回结果——对应AQS中的tryAcquire方法
  • 2. 获取锁,愿意进入队列等待,直到获取——对应AQS中的acquire方法

tryAcquire方法被protected修饰,参数是一个int值,代表了对status的修改,返回值是一个boolean类型,代表是否成功获得锁。

 
 protected boolean tryAcquire(int arg) {
  thro ne UnsupportedOperationException();
 }

上层业务可以重写次方法,实现获取锁之后的相关业务

如果选择等待锁,可以使用acquire方法,而不是自己实现复杂的排队逻辑。

如下所示,acquire的修饰符为public和final,意思是继承类可以直接调用我这个方法,而且不允许继承类擅自override,意思是这个方法一定能获取锁。

 
 public final void acquire(int arg) {
  if (!tryAcquire(arg) &&
   acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
   selfInterrupt();
 }

代码中尝试获取锁,当获取不到锁时,会创建一个等待者,并将等待者加入到等待队列。

看下这个addWaiter方法,进入方法后,创建一个Node节点,然后判断下队尾是否为空,非空,将节点加到队尾的后面,并返回节点。

 
 private Node addWaiter(Node mode) {
  Node node = ne Node(mode);

  for (;;) {
   Node oldTail = tail;
   if (oldTail != null) {
    node.setPrevRelaxed(oldTail);
    if (pareAndSetTail(oldTail, node)) {
     oldTail.next = node;
     return node;
    }
   } else {
    initializeSyncQueue();
   }
  }
 }

然后看下acquireQueued方法,看下等待队列是如何被消费的

 
 final boolean acquireQueued(final Node node, int arg) {
  boolean interrupted = false;
  try {
   for (;;) {
    final Node p = node.predecessor();
    // 如果线程的前置节点为头节点,且尝试获取锁成功,则进行返回
    // AQS 中,头节点为一个虚节点,即头节点并不是当前需要获取锁的节点
    // 当第二个节点获取锁之后,他就会变成头节点,头节点就会出队
    if (p == head && tryAcquire(arg)) {
     setHead(node);
     p.next = null; // help GC
     return interrupted;
    }
    // 如果线程需要被挂起
    // 这里没有选择自旋等待,而是判断是否挂起,可以防止性能问题
    // 理想情况下,需要将那些没有资格获取锁的节点挂起,再在适合的时间进行唤醒
    if (shouldParkAfterFailedAcquire(p, node))
     interrupted |= parkAndCheckInterrupt();
   }
  } catch (Throable t) { // 出现异常,则取消节点的等待,并进行清理工作
   cancelAcquire(node);
   if (interrupted)
    selfInterrupt();
   thro t;
  }
 }

我们看下如何判断线程是否该被挂起

下面代码中的四种状态,就是AQS源码中一开始列举实体属性时列举的状态。

shouldParkAfterFailedAcquire方法根据线程的状态信息,来判断线程是否需要被挂起。

 
 private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
  int s = pred.aitStatus;
  if (s == Node.SIGNAL)
   
   return true;
  if (s > 0) {
   
   do {
    node.prev = pred = pred.prev;
   } hile (pred.aitStatus > 0);
   pred.next = node;
  } else {
   
   pred.pareAndSetWaitStatus(s, Node.SIGNAL);
  }
  return false;
 }

parkAndCheckInterrupt方法对线程执行了挂起操作

 
 private final boolean parkAndCheckInterrupt() {
  LockSupport.park(this);  //挂起这个线程
  return Thread.interrupted(); // 返回线程的中断标志位,并将其赋值为false
 }

通过上述分析,AQS等待队列的执行效果如下

如果当前线程所在的节点处于头节点的后面一个,那么他将会不断尝试获取锁

 否则进行判断是否需要被挂起。

线程会被挂起的条件——线程的前驱节点不是头节点,并且aitStatus为SINGAL

这样可以保证head之后只会有一个节点在通过CAS获取锁,队列里其他线程都已被挂起或者正在被挂起,最大程度的避免无用的自旋消耗CPU

那么什么时候线程会被唤醒?

当线程使用完资源只会,会释放锁,并唤醒其他线程去获取锁。

这里使用到的方法是tryRelease和release方法。

从下方源码中可以看到如果继承类上层业务没有去override这个tryRelease方法,则会直接抛出异常。

 
 protected boolean tryRelease(int arg) {
  thro ne UnsupportedOperationException();
 }

 
 public final boolean release(int arg) {
  if (tryRelease(arg)) {
   Node h = head;
   if (h != null && h.aitStatus != 0)
    unparkSuessor(h);
   return true;
  }
  return false;
 }

假如线程尝试释放锁成功,那么下一步就会唤醒其他线程,可以看下上面源码中的unparkSuessor方法

 
 private void unparkSuessor(Node node) {
  
  int s = node.aitStatus;
  if (s < 0) {
   // 将head的atiStates设置为0,才不会影响其他函数的判断
   node.pareAndSetWaitStatus(s, 0); 
  }
  
  Node s = node.next;
  if (s == null || s.aitStatus > 0) {
   s = null;
   for (Node p = tail; p != node && p != null; p = p.prev)
    if (p.aitStatus <= 0)
     s = p;
  }
  if (s != null)
   LockSupport.unpark(s.thread);
 }

补充

JAVA中断

在Java中的挂起和中断是两个不同维度的概念。

JAVA中的中断,作用与线程对象,他并不会使得线程被挂起,而是会根据线程当前的活动状态来产生不同的效果。

  • 假设当前线程处于等待状态,那么对该线程进行interrupt,会使其抛出中断异常
  • 当线程处于运行状态,那么对该线程进行interrupt,只会改变线程中断的状态值,并不会影响该线程继续运行。

Copyright © 2016-2025 www.caominkang.com 曹敏电脑维修网 版权所有 Power by