xiaoyh 的个人博客

一个只会敲代码的咸鱼

0%

面试 —— Java 并发

并行与并发

  • 并行:多个任务在同一时刻进行
  • 并发:多个任务在同一段时间间隔进行

举个例子,并行就是两个人分别做任务 A 与任务 B,因此在执行期间无论什么时刻这俩任务都在进行;
而并发是一个人,一会儿做一下任务 A,一会儿做一下任务 B,这只能保证某个时间段这俩任务都在进行。

对于实际情况,CPU 在计算机中也是一种资源,作为资源实体的是 CPU 的时间片。当 CPU 时间片作为资源被分给进程时,进程里面的多个线程互相竞争 CPU 的时间片。

而对于单核 CPU 而言,就相当于一个人做多个任务,那么显然多个线程是并发执行。
那么多核 CPU 自然是相当于多个人做多个任务。不过往往也不是纯粹的并行(比如我做任务 A,你做任务 B),而是并行与并发并存(我和你都是交替地做任务 A 和任务 B)。即使对于单线程,CPU 也不会只让一个核去执行,而是让多个核去合作执行。

多线程的作用

多线程并不一定能够提高执行任务的效率,譬如对于单核 CPU 而言,往往多线程所消耗的时间比单线程要多,因为线程的上下文切换也需要消耗时间。

那么,多线程的作用是什么呢?

降低关键线程停顿时间

最典型的便是 UI 线程,因为 UI 线程往往需要不断循环去刷新界面,若在 UI 线程中插入一个耗时操作,就会堵塞住 UI 线程的循环,导致用户界面卡顿。因此可以开辟一个专门处理耗时操作的工作线程,来处理耗时操作。

提高总效率

对于多核 CPU 而言,线程数过少会让多个核的时间片空置,导致资源利用率不高。

比如让多核去执行单线程,即使 CPU 并不是只让一个核去执行单个线程,但最终任务完成的时间,取决于执行时间最长的核,在这个核执行期间,其余做的快的核的时间片被空置。

开辟多个线程,可以提高 CPU 的利用率,因此即使线程上下文的切换导致效率损耗,仍然能够提高总效率。理论上,应当有一个最佳线程数。

线程创建的方式

对于 Java ,线程创建的方式有三种:继承 Thread 类、实现 Runnable 接口、实现 Callable 接口。

继承 Thread 类

Thread 是 Java 中的线程类,可以通过直接重写其 run 方法来实现线程的创建。但这种创建线程的方式用的不多。

实现 Runnable 接口

实现 Runnable 接口的类,只能当做一个可以在线程中运行的任务,并不是真正的线程,所以仍然需要通过 Thread 来调用。

但这种创建线程的方式是最常用的,因为可以将其实例当作具体的“任务”塞进线程。常用的方式是塞入一个 Runnable 的匿名内部类,这样使得线程可以执行多种不同的任务,更加灵活。

1
2
3
4
5
6
new Thread(new Runnable() {
@Override
public void run() {
// ...
}
})

实现 Callable 接口

与 Runnable 相比,Callable 可以有返回值,返回值通过 FutureTask 进行封装。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
FutureTask<Integer> ft = new FutureTask<>(new Callable<Integer>() {
@Override
public Integer call() { // 模拟 3 秒钟后返回结果
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
return 123;
}
})

new Thread(ft).start();

// 调用 ft.get(),若此时子线程还未算出结果会造成堵塞,直至结果算出。
System.out.println(ft.get());

方法比较

一般都是通过实现接口来创建线程,因为:

  • 继承 Thread 类后就无法再继承其他类,而继承 Runnable 接口后还能继承其它类
  • 继承接口更加灵活,而继承 Thread 执行的任务无法修改。

线程的状态

根据《Java并发编程》,在 JVM 中线程包含 6 个状态:

  1. NEW(新建):初始状态,线程刚被构建,但还没有调用 start() 方法;
  2. RUNNABLE(运行):运行状态,正在 JVM 中运行,但在操作系统层面,它可能处于运行状态,也可能等待资源调度的就绪状态,即将操作系统中的就绪和运行两种状态笼统地称为“运行中”;
  3. BLOCKED(堵塞):堵塞状态,表示线程堵塞于锁;
  4. WAITING(无限期等待):该线程等待其它线程唤醒;
  5. TIME_WAITING(限时等待):无需等待其他线程唤醒,在一定时间后会被系统自动唤醒;
  6. TERMINATED(死亡):线程结束任务之后自己结束,或产生了异常而结束。

线程间的协作

Daemon

守护线程是程序运行时提供后台服务的线程,不属于程序不可或缺的部分,因此当所有非守护线程结束时,程序也就终止,同时会杀死所有非守护线程。

主线程属于非守护线程。

在线程启动之前可以通过 setDaemon() 将一个线程设置为守护线程。

yield()

静态方法 Thread.yield() 表示当前线程主动让出 CPU 时间片,以让其他线程去运行。

该方法只是对线程调度器的一个建议,而且也只是建议具有相同优先级的其它线程可以运行。

interrupt()

调用一个线程的 interrupt() 可以中断该线程

interrupt() 方法会根据线程中的 run 方法里是否有捕获 InterruptedException 异常的代码,而做出不同操作:

  • 如果没有捕获 InterruptedException 异常的代码,则 isInterrupted() 会返回 true;
  • 如果有捕获 InterruptedException 异常的代码,则会抛出 InterruptedException 异常并进行捕获,同时重置 isInterrupted 为 false 。

因此 interrupt() 并不一定能够中断该线程,比如:

1
2
3
4
5
while(!isInterrupted()) {
// ...
// 某操作堵塞了当前线程,比如网络 IO
while(true);
}

join()

在线程中调用另一个线程的 join() 方法,会将当前线程挂起,直到目标线程结束再继续执行。

wait() notify() notifyAll()

调用 wait() 可以将当前线程挂起,其他线程可以调用 notify()notifyAll() 来唤醒挂起的线程。

wait() 和 sleep() 的区别

  • wait() 是 Object 的方法,而 sleep() 是 Thread 的静态方法;
  • wait() 会释放锁,sleep() 不会;
  • wait() 只能在同步方法或者同步控制块中使用(即 synchronized),而 sleep 不用。

线程池

为了更方便地管理线程,以及降低资源的消耗,引入了线程池的概念。

线程池,本质上是一种对象池,用于管理线程资源。
在任务执行前,需要从线程池中拿出线程来执行。
在任务执行完成之后,需要把线程放回线程池。
通过线程的这种反复利用机制,可以有效地避免直接创建线程所带来的坏处。

线程池底层原理

当往线程池提交一个任务后:

  1. 判断核心线程池是否已满,若未满,则创建线程执行任务;
  2. 若核心线程池已满,则判断任务队列是否已满,若未满,则将任务置于队列中;
  3. 若任务队列已满,则判断线程池是否已满,若未满,创建线程执行任务;
  4. 若线程池已满,则按照拒绝策略对任务进行处理。

ThreadPoolExecutor 类是线程池 Executors 中最核心的一个类,它提供了很多个工厂方法,但最底层的还是如下的构造方法:

1
2
3
4
5
6
7
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler);

这个构造方法有 7 个参数,分别意味着:

  • corePoolSize:核心线程数;
  • maximumPoolSize:最大线程数;
  • keepAliveTime:存活时间。即当线程数超过核心线程数时,多余线程的存活时间;
  • unit:存活时间的单位。有毫秒、秒、分钟、小时、天等;
  • workQueue:任务队列。线程数超过核心线程数时,任务将放在任务队列里,它是一个 BlockingQueue 类型的对象;
  • threadFactory:线程工厂。用于创建一个线程;
  • handler:拒绝策略。当线程池和等待队列都满了后,需要通过该对象的回调函数来进行回调处理。

其中 workQueuethreadFactoryhandler 比较重要:

任务队列:workQueue

任务队列是 BlockingQueue 类型的,即堵塞队列。

在介绍堵塞队列之前,需要先介绍消费者生产者问题(也叫有限缓冲问题),该问题描述了两个线程以及它们共享的缓存队列在实际过程中发生的问题。

生产者的主要作用是生成一定量的数据放到队列中,然后重复此过程。与此同时,消费者也从队列中取出这些数据。

该问题的关键就是保证,生产者不会在队列满时加入数据,消费者也不会在队列空时消耗数据。

堵塞队列就是解决这个问题的,它提供了堵塞的 put()take()。如果队列为空 take() 将堵塞至队列有数据;如果队列已满 put() 将堵塞至队列有空闲位置。

对于堵塞队列,JDK 中有以下几种实现:

  1. ArrayBlockingQueue:队列有界,基于数组实现;
  2. LinkedBlockingQueue:队列可以有界也可以无界,基于链表实现;
  3. SynchronousQueue:不存储元素的堵塞队列,每个插入操作都必须等到另一个线程调用移除操作,否则插入操作将一直堵塞。该实现也是 newCachedThreadPool() 的默认实现;
  4. PriorityBlockingQueue:队列无界,优先堵塞队列。

线程工厂:threadFactory

threadFactory 是一个接口,只有一个 Thread newThread(Runnable r) 方法,用来创建线程对象。

Executors 的实现使用了默认的线程工厂 DefaultThreadFactory 。它的实现主要用于创建一个线程,线程的名字为 pool-{poolNum}-thread-{threadNum}。

若需要自定义线程名字,只需要自行实现 ThreadFactory,用于创建特定场景的线程即可。

拒绝策略:handler

所谓拒绝策略,就是当线程池满了、队列也满了的时候,我们对任务采取的措施。或者丢弃、或者执行、或者其他。JDK 自带四种策略:

  1. CallerRunsPolicy:在调用者线程执行;
  2. AbortPolicy:抛出 RejectedExecutionException 异常;
  3. DiscardPolicy:任务直接丢弃;
  4. DiscardOldestPolicy:丢弃队列里最旧的任务,并尝试执行当前任务。

这四种策略各有优劣,比较常用的是 DiscardPolicy,但是这种策略有一个弊端就是任务执行的轨迹不会被记录下来。所以,我们往往需要实现自定义的拒绝策略, 通过实现 RejectedExecutionHandler 接口的方式。

提交任务

往线程池中提交任务主要有两种方法,execute()submit()

execute() 用于提交不需要返回结果的任务。

submit() 用于提交一个需要返回结果的任务。该方法返回一个 Future 对象,通过调用这个对象的 get() 方法,我们就能获得返回结果。

如上面介绍,get() 方法会一直阻塞,直到返回结果返回。另外,也可以使用它的重载方法 get(long timeout, TimeUnit unit),这个方法也会阻塞,但是在超时时间内仍然没有返回结果时,将抛出异常 TimeoutException

关闭线程池

在线程池使用完成之后需要对线程池中的资源进行释放操作,这就涉及到关闭功能。我们可以调用线程池对象的 shutdown()shutdownNow() 方法来关闭线程池。

shutdown() 会将线程池状态置为 SHUTDOWN ,不再接受新的任务,同时会等待线程池中已有的任务执行完成再结束。

shutdownNow() 会将线程池状态置为 SHUTDOWN ,对所有线程执行 interrupt() 操作,清空队列,并将队列中的任务返回回来。

Executor 提供的一些线程池

SingleThreadExecutor

单一线程的线程池。若多个任务被提交到此线程池,那么会被缓存到队列。当线程空闲的时候,按照 FIFO 的方式进行处理。

1
2
// null 表示默认的或者无用的参数
new ThreadPoolExecutor(1, 1, 0, null, new LinkedBlockingQueue(), null, null);

FixedThreadPool

固定数量的线程池。和创建单一线程的线程池类似,只是可以并行处理任务的线程数更多一些罢了。当提交任务时:

  1. 若线程池未满,则创建新线程执行任务;
  2. 若线程池已满,则将任务塞入任务队列;
  3. 当线程空闲时,会从任务队列中取出任务执行。
1
new ThreadPoolExecutor(n, n, 0, null, new LinkedBlockingQueue(), null, null);

CachedThreadPool

带缓存的线程池,为每一个任务开辟一个线程,在空闲 60s 后被销毁。适用于负载较轻的场景,执行短期异步任务(可以使得任务快速得到执行,因为任务时间执行短,可以很快结束,也不会造成 cpu 过度切换)。

1
new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60, TimeUnit.SECONDS, new SynchronousQueue(), null, null);

ScheduledThreadPool

定时调度的线程池,适用于执行延时或者周期性任务。

互斥同步

Java 提供了两种锁机制来控制多个线程对共享资源的互斥访问,一个是 JVM 实现的 synchronized,另一个是 JDK 实现的 ReentrantLock。

synchronized

synchronized 是 JVM 分类的,就种类而言分为对象锁和类锁。

对象锁是单个对象的,因此一个类的多个对象各自有自己唯一的对象锁;类锁是属于类的,一个类的类锁有且仅有一个。

因此就对象锁而言,当一个对象存在两个同步方法时,多个线程也不能同时执行两个同步方法,因为锁资源是对象而不是单个方法。

ReentrantLock

ReentrantLock 是 java.util.concurrent(J.U.C)包中的锁。

1
2
3
4
5
6
7
8
9
10
11
12
public class LockExample {
private Lock lock = new ReentrantLock();

public void func() {
lock.lock();
try {
System.out.println("abc");
} finally {
lock.unlock(); // 确保释放锁,从而避免发生死锁。
}
}
}

java.util.concurrent 类库中还提供了 Condition 类来实现线程之间的协调。可以在 Condition 上调用 await() 方法使线程等待,其它线程调用 signal()signalAll() 方法唤醒等待的线程。

相比于 wait()await() 可以指定等待的条件,因此更加灵活。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class LockExample {
private Lock lock = new ReentrantLock();
private Condition condition = lock.newCondition();

public void before() {
lock.lock();
try {
System.out.print("before ");
condition.signalAll();
} finally {
lock.unlock();
}
}

public void after() {
lock.lock();
try {
condition.await();
System.out.print("after");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}

// 输出:before after

两者比较

  1. synchronized 是 JVM 实现的,而 ReentrantLock 是 JDK 实现的;
  2. 新版本 Java 对 synchronized 进行了很多优化,例如自旋锁等,synchronized 与 ReentrantLock 大致相同;
  3. 当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。ReentrantLock 可中断,而 synchronized 不行;
  4. 公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁。synchronized 中的锁是非公平的,ReentrantLock 默认情况下也是非公平的,但是可以设置为公平的。
  5. 一个 ReentrantLock 可以同时绑定多个 Condition 对象。

除非需要使用 ReentrantLock 的高级功能,否则优先使用 synchronized。

这是因为 synchronized 是 JVM 实现的一种锁机制,JVM 原生地支持它,而 ReentrantLock 不是所有的 JDK 版本都支持。

并且使用 synchronized 不用担心没有释放锁而导致死锁问题,因为 JVM 会确保锁的释放。

J.U.C - AQS

java.util.concurrent(J.U.C)大大提高了并发性能,AQS 被认为是 J.U.C 的核心。

CountDownLatch

CountDownLatch 用来控制一个或者多个线程等待多个线程。

其维护了一个计数器 cnt,每次调用 countDown() 方法会让计数器的值减 1,减到 0 的时候,那些因为调用 await() 方法而在等待的线程就会被唤醒。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class CountdownLatchExample {
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(10);
new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10; i++) {
System.out.print(i + " ");
countDownLatch.countDown();
}
}
}).start();
countDownLatch.await();
System.out.print("end");
}
}

// 输出: 0 1 2 3 4 5 6 7 8 9 end

CyclicBarrier

让一组线程到达一个同步点后再一起继续运行,在其中任意一个线程未达到同步点,其他到达的线程均会被阻塞。

CyclicBarrier 和 CountdownLatch 的一个区别是,CyclicBarrier 的计数器通过调用 reset() 方法可以循环使用,所以它才叫做循环屏障。

CyclicBarrier 有两个构造函数,其中 parties 指示计数器的初始值,barrierAction 在所有线程都到达屏障的时候会执行一次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class CyclicBarrierExample {
public static void main(String[] args) {
CyclicBarrier cyclicBarrier = new CyclicBarrier(5);
for (int i = 0; i < 5; i++) {
new Thread(new Runnable() {
@Override
public void run() {
System.out.print("before ");
try { // cyclicBarrier 调用 await() 会使计数器减一
cyclicBarrier.await();
} catch (InterruptedException | BrokenBarrierException e) {
e.printStackTrace();
}
System.out.print("after ");
}
}).start();
}
}
}

// 输出: before before before before before after after after after after

Semaphore

Semaphore 类似于操作系统中的信号量,可以控制对互斥资源的访问线程数。

线程安全问题

举个常见的例子。当两个线程对同一个共享资源进行基于读取值的修改操作的时候,若线程 a 读取了共享资源,且还未将基于读取值得出的结果同步至共享资源时,线程 b 就已经读取并修改了共享资源,则会导致线程 b 的结果被线程 a 覆盖。

Java 内存模型

Java 内存模型试图屏蔽各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。

处理器上的寄存器的读写的速度比内存快几个数量级,为了解决这种速度矛盾,在它们之间加入了高速缓存。

每个线程有自己的工作内存,工作内存存储在高速缓存或者寄存器中,在工作内存中保存了该线程使用的变量的主内存副本拷贝。

线程只能直接操作工作内存中的变量,不同线程之间的变量值传递需要通过主内存来完成。

加入高速缓存带来了一个新的问题:缓存一致性。如果多个缓存共享同一块主内存区域,那么多个缓存的数据可能会不一致,需要一些协议来解决这个问题。

Java 内存模型定义了 8 个操作来完成主内存和工作内存的交互操作:

  1. read:把一个变量的值从主内存传输到工作内存中;
  2. load:在 read 之后执行,把 read 得到的值放入工作内存的变量副本中;
  3. use:把工作内存中一个变量的值传递给执行引擎;
  4. assign:把一个从执行引擎接收到的值赋给工作内存的变量;
  5. store:把工作内存的一个变量的值传送到主内存中;
  6. write:在 store 之后执行,把 store 得到的值放入主内存的变量中;
  7. lock;
  8. unlock 。

内存模型三大特性

原子性

原子性表示这个操作是最小的工作单位,不可分割。Java 内存模型保证了 read、load、use、assign、store、write、lock 和 unlock 操作具有原子性,例如对一个 int 型的变量做 assign 赋值操作,这个操作就是原子性的。不过,Java 内存模型允许虚拟机将没有被 volatile 修饰的 64 位数据(long,double)的读写操作划分为两次 32 位的操作来进行,即 load、store、read 和 write 操作可以不具备原子性。

造成绝大多数线程安全问题的原因,都是因为操作不具备原子性。譬如让多个线程对一个 int 做自增操作。首先自增操作并不是一个原子性操作,它的本质是先将数读取到工作内存,修改为原来的值 + 1 再同步回去。那么假设两个线程同时读取,那么最后的结果自然是 1 而不是 2 。

可见性

可见性指当一个线程修改了共享变量的值,其它线程能够立即得知这个修改。Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值来实现可见性的。

有序性

有序性是指:在本线程内观察,所有操作都是有序的。在一个线程观察另一个线程,所有操作都是无序的。

无序是因为发生了指令重排序。在 Java 内存模型中,允许编译器和处理器对指令进行重排序,重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。

线程本地存储(ThreadLocal)

线程本地存储即为每一个线程都创建一个副本,这样无论线程如何修改这个副本,也不会影响其它的线程。非常适合以线程为作用域的场景。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class ThreadLocalExample {
public static void main(String[] args) {
ThreadLocal threadLocal = new ThreadLocal();
new Thread(() -> {
threadLocal.set(1);
System.out.println(threadLocal.get()); // 1
threadLocal.remove();
}).start();
new Thread(() -> {
threadLocal.set(2);
System.out.println(threadLocal.get()); // 2
threadLocal.remove();
}).start();
}
}

每个 thread 都有一个 ThreadLocalMap,当该线程对某 threadLocal 进行 set 操作时,就是在这个线程的 ThreadLocalMap 中添加一个键值对,其中 key 为该 threadLocal 对象,value 为设置的值。

synchronized 的优化

自旋锁

互斥同步进入阻塞状态的开销都很大,应该尽量避免。在许多应用中,共享数据的锁定状态只会持续很短的一段时间。自旋锁的思想是让一个线程在请求一个共享数据的锁时执行忙循环(自旋)一段时间,如果在这段时间内能获得锁,就可以避免进入阻塞状态。

自旋锁虽然能避免进入阻塞状态从而减少开销,但是它需要进行忙循环操作占用 CPU 时间,它只适用于共享数据的锁定状态很短的场景。

锁消除

锁消除是指对于被检测出不可能存在竞争的共享数据的锁进行消除。

锁消除主要是通过逃逸分析来支持,如果堆上的数据不可能逃逸出去被其它线程访问到,那么就可以把它们当成私有数据对待,也就可以将它们的锁进行消除。

锁粗化

如果一系列的连续操作都对同一个对象反复加锁和解锁,频繁的加锁操作就会导致性能损耗。

如果 JVM 探测到由这样的一串零碎的操作都对同一个对象加锁,将会把加锁的范围扩展(粗化)到整个操作序列的外部。

偏向锁和轻量级锁

JDK 1.6 引入了偏向锁和轻量级锁,从而让锁拥有了四个状态:无锁状态(unlocked)、偏向锁状态(biasble)、轻量级锁状态(lightweight locked)和重量级锁状态(inflated)。

以下是 HotSpot 虚拟机对象头的内存布局,这些数据被称为 Mark Word。其中 tag bits 对应了五个状态,这些状态在右侧的 state 表格中给出。除了 marked for gc 状态,其它四个状态已经在前面介绍过了。

偏向锁的思想是偏向于第一个获取锁对象的线程,这个线程在之后获取该锁就不再需要进行同步操作,甚至连 CAS 操作也不再需要。

当锁对象第一次被线程获得的时候,进入偏向状态,标记为 1 01。同时使用 CAS 操作将线程 ID 记录到 Mark Word 中,如果 CAS 操作成功,这个线程以后每次进入这个锁相关的同步块就不需要再进行任何同步操作。

当有另外一个线程去尝试获取这个锁对象时,偏向状态就宣告结束,此时撤销偏向(Revoke Bias)后恢复到未锁定状态或者轻量级锁状态。

轻量级锁是相对于传统的重量级锁而言,它使用 CAS 操作来避免重量级锁使用互斥量的开销。对于绝大部分的锁,在整个同步周期内都是不存在竞争的,因此也就不需要都使用互斥量进行同步,可以先采用 CAS 操作进行同步,如果 CAS 失败了再改用互斥量进行同步。

当尝试获取一个锁对象时,如果锁对象标记为 0 01,说明锁对象的锁未锁定(unlocked)状态。此时虚拟机在当前线程的虚拟机栈中创建 Lock Record,然后使用 CAS 操作将对象的 Mark Word 更新为 Lock Record 指针。如果 CAS 操作成功了,那么线程就获取了该对象上的锁,并且对象的 Mark Word 的锁标记变为 00,表示该对象处于轻量级锁状态。

如果 CAS 操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的虚拟机栈,如果是的话说明当前线程已经拥有了这个锁对象,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程线程抢占了。如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁。