在Java编程中有一个每个程序猿想躲躲不开遇到了又十分头疼的问题,即多线程的线程安全问题。这块知识应该可以算是Java中比较麻烦的一块知识之一了,今天就来谈一谈Java中如何解决线程安全问题,以及各种锁的区别。
1.存在共享数据(临界资源)
2.存在多条线程共同操作这些数据
解决问题的根本方法:同一时刻有且只有一个线程在操作共享数据,其它线程必须等到该线程处理完数据后再对共享数据进行操作。
互斥性:即在同一时间只允许一个线程持有某个对象锁,通过这种特性来实现多线程的协调机制,这样在同一时间只有一个线程对需要同步的代码块(复合操作)进行访问。互斥性也称为操作的原子性。
可见性:必须确保在锁被释放之前,对共享变量所做的修改,对于随后获得锁的另一个线程是可见的(即在获得锁时应获得最新共享变量的值),否则另一个线程可能是在本地缓存的某个副本上继续操作,从而引起不一致。
对于Java来说,Synchronized关键字可以满足上述两种特性。
注:Synchronized锁的不是代码,而是对象。
获取对象锁的两种用法:
1.同步代码块(synchronized(this),synchronized(类实例对象)),锁是括号中的对象。
2.同步非静态方法(synchronized method),锁是当前对象的实例。
获取类锁的两种用法:
1.同步代码块(synchronized(对象.getClass()),synchronized(类名.class)),锁是括号内对象的类对象(class对象)。
2.同步静态方法(synchronized static method),锁是当前对象的class对象。
ThreadDemo.java:
public class ThreadDemo implements Runnable{
public void asynMethod() {
System.out.println(Thread.currentThread().getName()+" 开始运行(异步方法)");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+" 结束运行(异步方法)");
}
public synchronized void syncMethod() {
System.out.println(Thread.currentThread().getName()+" 开始运行(同步方法)");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+" 结束运行(同步方法)");
}
public void syncBlock() {
synchronized(this) {
System.out.println(Thread.currentThread().getName()+" 开始运行(同步代码块)");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+" 结束运行(同步代码块)");
}
}
public void syncClass() {
synchronized (this.getClass()) {
System.out.println(Thread.currentThread().getName()+" 开始运行(以class文件为锁对象的同步代码块)");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+" 结束运行(以class文件为锁对象的同步代码块)");
}
}
public static synchronized void syncStaticMethod() {
System.out.println(Thread.currentThread().getName()+" 开始运行(静态同步方法(以class文件为锁))");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+" 结束运行(静态同步方法(以class文件为锁))");
}
@Override
public void run() {
// TODO Auto-generated method stub
if(Thread.currentThread().getName().startsWith("ASYN")) {
asynMethod();
}else if(Thread.currentThread().getName().startsWith("SYNC_METHOD")) {
syncMethod();
}else if(Thread.currentThread().getName().startsWith("SYNC_BLOCK")) {
syncBlock();
}else if(Thread.currentThread().getName().startsWith("SYNC_CLASS")) {
syncClass();
}else if(Thread.currentThread().getName().startsWith("SYNC_STATIC")) {
syncMethod();
}
}
}
ThreadTest.java:
public class ThreadTest {
public static void main(String[] args) {
ThreadDemo threadDemo = new ThreadDemo();
Thread thread1 = new Thread(threadDemo,"ASYN_Thread1");
Thread thread2 = new Thread(threadDemo,"ASYN_Thread2");
Thread thread3 = new Thread(threadDemo,"SYNC_METHOD_Thread1");
Thread thread4 = new Thread(threadDemo,"SYNC_METHOD_Thread2");
Thread thread5 = new Thread(threadDemo,"SYNC_BLOCK_Thread1");
Thread thread6 = new Thread(threadDemo,"SYNC_BLOCK_Thread2");
Thread thread7 = new Thread(threadDemo,"SYNC_STATIC_Thread1");
Thread thread8 = new Thread(threadDemo,"SYNC_STATIC_Thread2");
thread1.start();
thread2.start();
thread3.start();
thread4.start();
thread5.start();
thread6.start();
thread7.start();
thread8.start();
}
}
1.有线程访问对象的同步代码块时,另外的线程可以访问该对象的非同步代码块。
2.若锁住的是同一个对象,一个线程在访问对象的同步代码块时,另一个访问对象的同步代码块的线程会被阻塞。
3.若锁住的是同一个对象,一个线程在访问对象的同步方法时,另一个访问对象同步方法的线程将会被阻塞。
4.若锁住的是同一个对象,一个线程在访问对象的同步代码块时,另一个访问对象同步方法的线程会被阻塞,反之亦然。
5.同一个类的不同对象的对象锁互不干扰。
6.类锁由于也是一种特殊的对象锁,因此表现和上述的1、2、3、4一致,而由于一个类只有一把对象锁,所以同一个类的不同对象使用类锁将会是同步的。
7.类锁和对象锁互不干扰。
简要地了解了Synchronized之后,我们将进一步深入理解Synchronized底层原理。
Hotspot虚拟机中,对象在内存中的布局分为如下三个部分:
1.对象头
2.实例数据
3.对齐填充
由于实例数据和对其填充我也不太了解,而且对这部分内容没有很大的关联,所以暂时不提。
详细聊聊对象头:
对象头分为两部分:
1.Mark Word:默认存储对象的Hashcode、分代年龄、锁类型、锁标志等信息。
2.Class Metadata Address:类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的数据。
由于对象头的信息是跟对象运行没有关系的额外数据,所以考虑到运行效率的问题,MarkWord被设计成一个非固定的数据结构,以便存储更多的有效数据,它会根据对象本身的状态,复用自己的存储空间。
Java对象从诞生起,就在内部封装了一个看不到的锁——Moditor(监视器锁),我们可以把它当作一个同步工具。Monitor和对象之间有多种关系,例如它可以和对象一起生成,也可以当有对象需要获取锁时再自动生成。关于Monitor的构造函数(C++)如下:
ObjectMonitor() {
_header = NULL;
_count = 0;
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL;
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ;
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
}
首先先记住这几个属性(从上往下):count(计数器),object,owner(当前持有锁的线程),WaitSet(等待池),_EntryList(锁池)。
从源码中可以看出,Monitor中有两个队列,一个是WaitSet,另外一个就是_EntryList,这两个队列分别是等待池和锁池,当多个线程都想获取锁时,就一起进入锁池(EntryList),只能有一个线程获取到锁,而其余线程必须在锁池中等待,而成功获取到锁的线程就会进入_object区,并把Monitor中的_owner属性更改为当前线程,另外Monitor中的计数器_count将会加一。当线程调用wait()方法时,owner属性将会被置为null,count也会减一,当前线程会进入_WaitSet中等待被唤醒。
由此看来,Monitor锁存在于每个Java对象的对象头中,Synchronized关键字就是通过这种方式去获取锁的,这也是为什么Java中任意对象都可以作为锁的原因。
public class ThreadClassDemo {
public void syncPrint() {
//同步代码块
synchronized(this) {
System.out.println("hello——sync block");
}
}
public synchronized void syncMethodPrint() {
System.out.println("hello——sync method");
}
}
在刚才写的Java代码目录下找到放class文件的目录,使用javap -verbose ThreadClassDemo.class
命令,查看字节码文件。
使用同步代码块的字节码文件
使用同步方法的字节码文件:
我们可以看到,在使用同步代码块的字节码中显示地使用了monitorenter和monitorexit代表加锁和释放锁,而使用同步方法的字节码中并没有monitorenter和monitorexit的存在。其实在同步方法进行同步的过程中是不显示调用monitorenter和monitorexit的。那么它是怎么实现同步的呢?我们可以看到,在同步方法的字节码文件中有一个属性,flags,其中包含了ACC_SYNCHRONIZED,这个标志可以区分一个方法是否为同步方法,当方法调用时,系统检测该方法是否有ACC_SYNCHRONIZED标志,那么执行该方法的线程将会持有monitor,然后执行完成,最后释放monitor。
之前在学习Synchronized的有关知识时总会有人说,Synchronized是一把重量级锁。的确,在早期版本中(Java1.6以前),Synchronized属于重量级锁,因为其主要依赖于MutexLock实现,在每次加锁时都需要从用户态切换到核心态,这对CPU来说是一个重量级的操作。在高并发的场景中,每次加锁都进行用户态核心态转换,这是不现实的,这也是我后面要对比着Synchronized来说ReentrantLock的原因。但是今时不同往日,在JDK1.6之后,Synchronized的性能得到了很多的改善,再也不是当时那个被人嗤之以鼻的重量级锁了。
许多情况下,共享数据的锁定状态持续时间较短,为了这段时间去切换或者挂起线程不值得。在如今多处理器的环境下,完全可以不让没有获取到锁的线程阻塞,而是让其多等待一会,但不放弃CPU的执行时间,这个行为被称为自旋,即通过循环让线程等待,而不让出CPU。这种策略在锁占用时间非常短的情况下,效率会很高,因为避免的频繁的上下文切换。但是如果一把锁被其他线程长时间占用,则这种循环等待的方式将会一致占用CPU,造成许多不必要的性能开销,如果存在这种情况,就应该使用传统的方式直接挂起未获取到锁的线程。
自适应自旋锁,顾名思义,其自旋的次数不再固定,而是根据当前线程的上一个线程获取到锁的情况来决定自旋的次数。如果上一个线程没有成功获取到锁,那么JVM会认为当前线程获取锁的可能性也很大,而适当增加自旋次数来避免锁切换造成的开销;如果上一个线程迟迟未获取到锁,那么JVM会认为当前线程获取到锁的概率也不大,于是早早地结束自旋,以免浪费CPU资源。
锁消除是虚拟机的另一种优化策略,在JIT编译过程中,自动消除一些JVM认为不会遇到竞争的锁,即当JVM判定,某个加锁的资源是不可能被共享的资源,就将它的锁消除。
public class Test{
public static void main(String[] args){
StringBuffer sb = new StringBuffer();
int i = 0;
while(i<100){
sb.append("test")
i++;
}
}
}
例如上述代码,我们知道StringBuffer是线程安全的,所以在每次调用append方法时,都会尝试加锁,但是在上述例子中,只有一个对象反复地加锁释放锁加锁释放锁…造成频繁切换,此时JVM会自动将锁粗话,即将该锁的粒度扩大到循环外,即进入循环时加锁,然后在进行append操作时不再重复加锁。
Synchronized存在四种状态,分别是无锁、偏向锁、轻量级锁、重量级锁,根据场景的不同自动进行升级或降级,升级的方向为 无锁–>偏向锁–>轻量级锁–>重量级锁。
很好理解,就是没有锁,不加锁,异步,嗯。
偏向锁的主要目的是:减少同一线程获取锁的代价。在大多数情况下,锁不存在多线程竞争,还总是由同一线程重复获得,如果按照正常流程,该线程每次在获取这把锁的时候都需要进行一次繁琐的申请流程。为了解决这个问题,hotspot在优化时引入了偏向锁,其核心思想是:如果一个线程获取了锁,那么锁就进行偏向模式,此时Mark Word的结构就变成了偏向锁结构(锁标志位为1 01,详情看上文中对象头结构图),当该线程再次请求锁时,无需再进行任何同步操作,只需要检查Mark Word的锁标志位为偏向锁和当前线程Id等于Mark Word中的线程ID即可,这样就减少了大量锁申请的操作。
注:偏向锁不适合锁竞争比较激烈的多线程场合
关于偏向锁我还看过一段解释是比较容易理解的:
一个对象刚开始实例化的时候,没有任何线程来访问它的时候。它是可偏向的,意味着,它现在认为只可能有一个线程来访问它,所以当第一个线程来访问它的时候,它会偏向这个线程,此时,对象持有偏向锁。偏向第一个线程,这个线程在修改对象头成为偏向锁的时候使用CAS操作,并将对象头中的ThreadID改成自己的ID,之后再次访问这个对象时,只需要对比ID,不需要再使用CAS在进行操作。
一旦有第二个线程访问这个对象,因为偏向锁不会主动释放,所以第二个线程可以看到对象时偏向状态,这时表明在这个对象上已经存在竞争了,检查原来持有该对象锁的线程是否依然存活,如果挂了,则可以将对象变为无锁状态,然后重新偏向新的线程,如果原来的线程依然存活,则马上执行那个线程的操作栈,检查该对象的使用情况,如果仍然需要持有偏向锁,则偏向锁升级为轻量级锁,(偏向锁就是这个时候升级为轻量级锁的)。如果不存在使用了,则可以将对象回复成无锁状态,然后重新偏向。
参考链接:java 偏向锁、轻量级锁及重量级锁synchronized原理
轻量级锁是由偏向锁升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程进入锁竞争时,偏向锁会升级为轻量级锁。(如果当前持有锁的那个线程依然存活,才会升级,否则继续偏向现在竞争锁的这个线程)。轻量级锁适用于线程交替执行,交替上锁的情况,如果出现同一时间内多个线程竞争同一把锁,轻量级锁将升级为重量级锁。
轻量级锁的加锁过程:
1.在代码进入同步块执行的时候,如果同步对象锁状态为无锁状态,虚拟机首先在当前线程的栈帧中建立一个名为Lock Record的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为Displaced Mark Word。
2.拷贝对象头中的Mark Word复制到Lock Record中。
3.拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的_owner指针指向object mark word,如果更新成功则执行步骤4,否则执行步骤5。
4.如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的标志位设置为00,表示此对象处于轻量级锁的锁定状态。
5.如果这个更新操作失败了,虚拟机首先会检查对象的MarkWord是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁,轻量级锁就要升级为重量级锁,锁标志的状态值变为10,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态,而当前线程便尝试使用自旋来获取锁。
轻量级锁认为竞争存在,但是竞争的程度很轻,一般两个线程对于同一个锁的操作都会错开,或者说稍微等待一下(自旋),另一个线程就会释放锁。 但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止CPU空转。
当线程释放锁时,Java内存模型会把该线程对应的本地内存中的共享变量刷新到主内存中。而当线程获取锁时,Java内存模型该线程对应的本地内存置为无效,从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。
这里可能会涉及到一些ReentrantLock的源码解析,大概需要这些前导知识:
AQS相关知识:CAS、自旋锁、park、unPark
其中,自旋上文中有提到,CAS会在下文中提到,可以先下去看完再回头来看这一段,park,unpark只需要知道其作用就好了,没有了解过的小伙伴可以先自行百度一下,影响不大。
再入锁(不是特别喜欢这个翻译,下文还是称为ReentrantLock),其语义和Synchronized基本相同,基于AQS实现,由Doug Lea大神编写,在Java1.5版本中被引入,其最初想解决的痛点是Synchronized每次加锁都需要进行繁琐的用户态内核态切换(Java1.6以前),造成资源的浪费。而ReentrantLock的加锁在线程交替执行的场景下,完全在JVM层就可以进行,而无需像Synchronized一样每次加锁都需要调用Native方法,再进行系统调用,这样避免了频繁的用户内核态切换,换来了速度的提高。
要讲明白这个过程,会稍微有点长,也会稍微有点难,也有人说让我不要刚开始学了一点东西就心浮气躁,开始写并发之类的内容,但我还是希望自己能输出一些源码的东西,我会本着最客观最实际的出发点用自己的方式讲这段文字,希望大家也是本着相互学习的心态来看这一段。所以如果有什么地方出了bug,还是老规矩,评论区或者直接联系我私聊我都可以。
我会假设有A、B、C三个线程去获取锁的场景,以这个场景为基础,来谈谈ReentrantLock的加锁过程(我会将整个过程写在代码的注释中,中间会有一些跳着看的地方)。
/*AQS是由Node构成的,每个Node中保存了当前线程的前驱节点、后继节点,同步状态、等待状态。当然,还包含了一个线程实体*/
//同步队列的头节点
private transient volatile Node head;
//同步队列的尾节点
private transient volatile Node tail;
//锁是否被占用,0表示自由,1表示被占用
private volatile int state;
由于ReentrantLock是基于AQS实现的(ReentrantLock内部使用了Sync,是AQS的子类),所以必须要先明白,AQS的结构是什么样的。
public ReentrantLock(boolean fair) {
//构造方法
//如果传入的是true,则创建一个公平锁,如果传入false,创建一个非公平锁
//默认是非公平锁
//非公平锁和公平锁,都继承自ReentrantLock的静态内部类Sync,Sync继承自AQS
sync = fair ? new FairSync() : new NonfairSync();
}
1.首先A线程尝试获取锁(调用reentrantLock.lock()方法)
//线程A获取锁
public void lock() {
sync.lock();
}
2.进入公平锁lock()方法
final void lock() {
/*公平锁的lock()方法实际上封装的是acquire方法,而非公平锁是直接进行CAS操作尝试获取锁
如果获取锁失败,调用acquire方法,看到这的同学跳转到第3点,acquire方法。
*/
/*==================================分割线==================================*/
acquire(1);
/*根据3,acquire方法正常返回,于是lock执行结束,正常返回。*/
}
3.进入acquire方法
public final void acquire(int arg) {
/*首先调用tryAcquire方法尝试获取锁,为了方便,我直接把tryAcquire方法贴在下方
*(看到这里的小伙伴直接跳到下方代码tryAcquire方法代码
*/
/*==================================分割线==================================*/
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
/*根据下方代码,tryAcquire方法返回结果:true,这个if语句中将true取反,变为false,
*于是这个if语句不会被执行,acquire方法直接返回。
*/
}
//从acquire方法进入tryAcquire方法
protected final boolean tryAcquire(int acquires) {
//获取当前线程:线程A
final Thread current = Thread.currentThread();
//获取当前state状态,state是锁是否被占用的标志位,0表示自由,1表示被占用
//当前只有A线程开始尝试获取锁,那么锁肯定是自由的,state == 0,即c == 0、
int c = getState();
if (c == 0) {
//此时c一定等于0,于是到这里
//有一个hasQueuedPredecessors方法,这个方法是判断是否等待队列中有排在自己之前的元素。源代码我也贴在下方。(看到这的同学直接跳到第4点看hasQueuedPredecessors方法)
/*==================================分割线==================================*/
//根据下方第4点结果说明,hasQueuePredecessors返回false,这个if语句对这个结果取反,就变成了true。
//可以继续执行下一个条件:campareAndSetState(0,acquires),即CAS。
//CAS操作将state由0改为1,说明锁此时被占用,那么被谁占用了呢?
//执行setExclusiveOwnerThread(current),将锁的持有者改为当前线程。
//整个方法返回 true。
if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
4.进入hasQueuedPredecessors方法
public final boolean hasQueuedPredecessors() {
//将AQS的队尾元素赋值给t
Node t = tail;
//将AQS的队头元素赋值给h
Node h = head;
Node s;
/*A线程会进到这里,首先判断h是不是等于t
*线程A是第一个到达这里的,此时队列并没有被初始化,所以h == null,t==null,
*所以此时h != t为false,这个方法直接返回false
*/
return h != t &&
((s = h.next) == null || s.thread != Thread.currentThread());
}
通过以上4步,可以直接捋出一个线程A的加锁流程,线程A的加锁流程也是当整个AQS队列中不存在任何元素时的一个加锁流程,而怎么样算是加锁成功了呢?,实际上就是lock方法正常返回,即加锁成功,因为后面大家会看到,如果加锁失败,lock方法是不会正常返回的。
老规矩还是贴代码,首先线程B进入lock方法准备尝试持有锁。
1.线程B进入lock方法
final void lock() {
/*公平锁的lock()方法实际上封装的是acquire方法,而非公平锁是直接进行CAS操作尝试获取锁
如果获取锁失败,调用acquire方法。
*/
acquire(1);
}
2.线程B进入acquire方法
public final void acquire(int arg) {
/*线程B首先调用tryAcquire方法尝试获取锁
*(看到这里的小伙伴直接跳到3,tryAcquire方法)
/*==================================分割线==================================*/
*/
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
/*根据下方代码,tryAcquire方法返回结果:false,这个if语句中将false取反,变为true,
*于是这个if语句会继续往下判断,调用acquireQueue方法,而调用acquireQueue方法之前,会
*先调用addWaiter方法,看到这的同学跳到4,进入addWaiter方法
*/
/*==================================分割线==================================*/
/*根据4的结果,addWaiter方法返回了一个保存当前线程,即线程B的Node对象*/
/*==================================分割线==================================*/
/*于是继续执行acquireQueued方法,跳到6*/
}
3.线程B进入tryAcquire方法
//线程B从acquire方法进入tryAcquire方法
protected final boolean tryAcquire(int acquires) {
//获取当前线程:线程B
final Thread current = Thread.currentThread();
//获取当前state状态,state是锁是否被占用的标志位,0表示自由,1表示被占用
//当前线程A正在持有锁,所以state==1,即c==1
int c = getState();
if (c == 0) {
//此时c==1,于是不能到达这里
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
//判断当前线程是否为持有锁的线程
//显然线程B不是当前持有锁的线程,于是也到不了这里。
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
//线程B整个tryAcquire方法返回false
return false;
}
4.线程B进入addWaiter方法
private Node addWaiter(Node mode) {
//创建一个Node,并将其中的thread属性改为当前线程,也就是线程B
//Node中保存了当前线程,还保存了当前线程的前驱节点、后继节点,同步状态、等待状态。AQS是由Node组成的
Node node = new Node(Thread.currentThread(), mode);
//将tail赋值给pred,但此时tail为null
Node pred = tail;
if (pred != null) {
//此时pred显然为null,因为等待队列并没有被初始化,所以执行enq方法
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
//执行enq方法,跳到5,enq方法
enq(node);
/*==================================分割线==================================*/
//enq方法返回
//整个addWaiter方法返回,返回保存了当前线程的node
return node;
}
5.线程B进入enq方法
private Node enq(final Node node) {
//enq方法传入一个node,即保存了线程B的node
for (;;) {//死循环
//第一次循环:为正在运行的那个Thread创建一个Node,该Node的thread属性为null
//第二次循环:将保存了线程B的Node的prev指针指向第一次循环创建的Node
//最后形成一个队列,队列中的队首是保存了正在运行的线程(线程A)的Node,而紧随其后的就是线程B的Node
Node t = tail;
if (t == null) {
/*第一次循环进入这块代码:
这个CAS非常有意思,首先它是一个设置AQS队首的CAS操作,而队首等于新建的一个Node,只有当设置成功了,将队首赋值给队尾*/
/*这个操作正好是进行了一次初始化
*于是现在队列应该长这样:
*队列中只有一个元素,
*而head和tail同时指向同一个Node
*/
//然后enq方法进入第二次循环
if (compareAndSetHead(new Node()))
tail = head;
} else {
/*第二次循环进入这块代码
*将t赋值给node的prev,此时t==head,所以就是将head赋值给t的prev,node是什么?node就是保存了线程B的那个节点,也就是,保存了线程B的指向上一个节点的指针prev,让它指向我们新创建的这个node。
*然后CAS更新队尾,将队尾从刚才的指向队首,变为指向当前线程node
*最后返回这个队列t
*/
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
6.线程B进入acquireQueued方法
final boolean acquireQueued(final Node node, int arg) {
//该方法传入一个node,即保存了当前线程的node,当前线程即线程B
//arg = 1
boolean failed = true;
try {
boolean interrupted = false;
//死循环
for (;;) {
//首先将当前线程的node的前一个node 赋值给p
//此时p就等于线程B的前一个节点,在enq方法中我们可以得出,p节点就是装载了线程A的那个Node
final Node p = node.predecessor();
//第一次判断p==head是符合的,所以继续进行判断,tryAcquire(尝试加锁)
/*
*这里说两种情况。
*1.如果在这里线程A运行完毕释放了锁,那么尝试加锁肯定会成功
*那么就将head设置为线程B的node
*p.next即线程A的next指针,将其指向null,这是为了让其无引用,帮助GC回收
*然后整个方法返回false(这里为什么返回interrupted,后文会说)
*
*2.如果这里线程A没有运行完毕,那么尝试加锁失败。那么程序不会进入这个if,继续往下执行。
*其实这里的本质就是:在判断自己需要排队后,不立即park,而是先自旋一次,尝试获取锁,如果获取锁成功了,则直接就可以拿到锁,而不必进行上下文切换,如果尝试获取锁失败,再做别的操作,这样的尝试获取锁会重复两次,也就是说线程在park之前会自旋两次!
*/
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
//如果程序尝试获取锁失败,则会进入这个判断,执行shouldParkAfterFailedAcquire方法
//shouldParkAfterFiledAcquire传入两个参数,即装载了线程A的Node和装载了线程B的Node
//看到这的同学跳到7,有shouldParkAfterFailedAcquire方法。
/*==================================分割线==================================*/
//由7可知shouldParkAfterFailedAcquire方法返回false,于是进入下一次循环。
/*在第二次循环中,同样会再一次尝试获取锁,然后如果锁获取失败,
*同样会进入shouldParkAfterFailedAcquire方法
*然后由于第一次在该方法中已经将waitStatus修改为Node.SIGNAL了(表明线程是否已经准备好被阻塞并等待唤醒)
*所以shouldParkAfterFailedAcquire会返回true
*于是进入parkAndCheckInterrupt方法,这个方法中只有两行代码
*LockSupprt.park(this);//让线程进入阻塞状态
*return Thread.interrupted();//如果线程被打断则返回true,这个地方存在坑,后文会提到。
*所以线程B在两次自旋尝试加锁失败后,就会进入阻塞状态。
*这个方法结束,但不会正常返回,因为线程此时已经阻塞在这里了。
*所以最初调用的lock方法也不会得到正常返回,所以整个线程就被卡在lock方法的那一行。无法进入共享区域。
*这也是我刚才提到的,只要lock正常返回说明加锁成功,没有正常返回代表线程被阻塞。
*/
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
7.线程B进入shouldParkAfterFailedAcquire(在尝试获取锁失败后是否需要park)方法
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
/*
*shouldParkAfterFailedAcquire(在尝试获取锁失败后是否需要park)方法有三个作用:
*1、若pred.waitStatus状态位大于0,说明这个节点已经取消了获取锁的操作,doWhile循环会递归删除掉这些放弃获取锁的节点。
*2、若状态位不为Node.SIGNAL,且没有取消操作,则会尝试将状态位修改为Node.SIGNAL。
*3、若状态位是Node.SIGNAL,表明线程是否已经准备好被阻塞并等待唤醒。
*/
//该方法传入两个参数,即当前线程的node和当前node的pred
//首先将pred的waitStatus属性赋值给ws,当前waitStatus属性为0
int ws = pred.waitStatus;
//Node.SIGNAL说明该节点准备好被阻塞并等待唤醒,若节点没有设置为该状态,线程不会阻塞。当前节点的pred的waitStatus是没有被设置为该状态的
if (ws == Node.SIGNAL)
return true;
//当前ws等于0而不是大于0
if (ws > 0) {
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
//进入这个代码块
//尝试CAS改变 pred!pred!pred!(重要的事情说三遍)的waitStatus属性,从0改为Node.SIGNAL
//也就是说,后一个Node,会改变前一个Node的waitStatus属性,换言之,就是当前线程Node的waitStatus属性只能由后面的那一个节点改变,再换言之,前一个node的waitStatus标识着自己的等待状态。
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
//整个方法返回false
return false;
}
1.lock
//线程C获取锁
public void lock() {
sync.lock();
}
2.公平锁lock
final void lock() {
/*公平锁的lock()方法实际上封装的是acquire方法,而非公平锁是直接进行CAS操作尝试获取锁
如果获取锁失败,调用acquire方法。
*/
acquire(1);
}
3.acquire方法
public final void acquire(int arg) {
/*线程B首先调用tryAcquire方法尝试获取锁,看到这的同学进入4.tryAcquire方法*/
/*==================================分割线==================================*/
/*tryAcquire方法返回false,取反,于是继续往下判断,执行addWaiter方法,看到这的同学跳转进入5,addWaiter方法*/
/*==================================分割线==================================*/
/*addWaiter方法返回保存线程C的Node,然后执行acquireQueued方法,看到这的小伙伴进入6,acquireQueued方法*/
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
4.tryAcquire方法
//线程C从acquire方法进入tryAcquire方法
protected final boolean tryAcquire(int acquires) {
//获取当前线程:线程C
final Thread current = Thread.currentThread();
//获取当前state状态,state是锁是否被占用的标志位,0表示自由,1表示被占用
//当前线程A正在持有锁,所以state==1,即c==1
int c = getState();
if (c == 0) {
//此时c==1,于是不能到达这里
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
/*这个判断是用来判断线程是否重入的,如果持有锁的线程是当前线程,那么无需进行CAS获取锁,直接上锁*/
//判断当前线程是否为持有锁的线程
//显然线程C不是当前持有锁的线程,于是也到不了这里。
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
//线程C整个tryAcquire方法返回false
return false;
}
5.addWaiter方法
private Node addWaiter(Node mode) {
//Node中保存了当前线程,还保存了当前线程的前驱节点、后继节点,同步状态、等待状态。AQS是由Node组成的
//创建一个Node,并将其中的thread属性改为当前线程,也就是线程C
Node node = new Node(Thread.currentThread(), mode);
//将tail赋值给pred,但此时tail为保存了线程B的Node
Node pred = tail;
if (pred != null) {
//此时pred不等于null,队列已经被初始化,而且其中有两个Node,于是进入这个判断内
//将pred赋值给线程C的Node的前驱指针。
//此时pred就是保存线程B的Node
node.prev = pred;
//进行一次CAS操作,将tail指向保存了线程C的Node
//此时整个AQS队列应该是这样的
//正在持有锁的Node(Head)<——>保存线程B的Node<——>保存线程C的Node(Tail)
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
//方法返回。返回值为保存线程C的Node
}
}
enq(node);
return node;
}
6.acquireQueued方法
final boolean acquireQueued(final Node node, int arg) {
//该方法传入一个node,即保存了当前线程的node,当前线程即线程C
//arg = 1
boolean failed = true;
try {
boolean interrupted = false;
//死循环
for (;;) {
//首先将当前线程的node的前一个node 赋值给p,即p==保存了线程B的Node
final Node p = node.predecessor();
//p此时在队列中排第二个位置,所以它不是头部,不会进入该判断。
/*
*这里解释一下它的用意,当一个线程的前一个线程是队首,那么它在进入这个方法时,
*有可能队首的线程已经执行完毕并将锁释放
*那么可以去尝试获取一下锁
*但是如果你的前一个Node都不是队首,说明你的前面还有人在排队
*所以你就不用再去尝试获取锁了,因为还轮不到你。
*所以直接放弃执行这段代码,乖乖去排队吧。
*/
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
//于是进入这个判断,直接判断是否需要park
//流程和刚才线程B执行shouldParkAfterFailedAcquire方法是一样的
//所以我就不重新贴代码了。
//最终线程C会在这里被park住
//方法结束,但是不会正常返回,线程C就直接阻塞在这了,等待前面的线程执行完来唤醒他吧。
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
当锁为空闲状态时,第一个线程尝试获取锁,就是上文中的线程A获取锁的状况。此时队列也没有初始化,实际上在线程A成功获取锁后,队列也也不会初始化。可以看到,线程A获取锁的整个流程是十分流畅的,而且没有用到任何系统调用,没有进行用户态内核态转换,所有的操作完全在Java中被完成。所以,如果线程是交替执行的,那么使用ReentrantLock来作为同步手段效率是很高的。因为不会存在线程阻塞的情况,加锁也无需进行系统调用。
当锁已经被占用,第二个线程尝试去获取锁,就可以对应上文中线程B获取锁的场景。此时队列并没有被初始化,所以线程B会去初始化整个队列,但是此时队列中并不是只有线程B,而是在线程B前面存在一个Node,这个Node虽然其thread属性为null,但是我们可以当作它代表的就是当前持有锁的线程A。由于线程A从始至终就没有参与过排队,所以第一个Node的thread属性才会是null;或者你也可以这么理解,在一个队列中,站在队列的第一个人并不是在排队,而是正在办理业务,而之后排在第二个或者再往后的,才能算是正在排队。说了这么多,就是想表明现在队列中存在两个Node。然后当B创建完Node后,由于线程B是在队列中的第二个,A执行完了就轮到它了,所以此时线程B会看看此时线程A是不是已经执行完毕释放锁了,于是它会去尝试获取一下锁,这就是第一次自旋。如果尝试获取锁失败,这下完了,八九不离十是要被park了,于是就收拾收拾吧,把前一个节点的waitStatus改为Node.SIGNAL,代表已经准备好被阻塞了,但是在park之前,线程B会再进行一次垂死挣扎,万一呢!万一这下可以获取到锁了呢?!!!,于是它又尝试获取了一下锁,如果再次失败,则直接就被park阻塞了。
当锁已经被占用,且队列也初始化了,队列前面也有线程正在排队。这就是线程C遇到的状况,此时线程C也会去尝试获取一下锁,肯定它是获取不到的,因为前面还有排队的,再怎么轮也轮不到你啊。所以它直接去等待排队了,在等待排队时,它看了看自己的地位,第三,于是也别尝试获取锁了,前面还一堆人等着呢,轮不到自己,于是直接就乖乖去排队了,然后就被park在当场。但是,排第三不代表失去希望啊!它会自旋两次,看看自己有没有机会晋升到第二的位置上,如果到了第二的位置上,那么它还是会去尝试获取锁的。
其实很简单,成功获取锁的线程Lock可以正常返回,正常返回后就可以继续执行Lock之后的代码。而没有获取到锁的线程,都被阻塞在lock里了,无法接着往下执行。
先上parkAndCheckInterrupt源码:
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this);//调用park()使线程进入waiting状态
return Thread.interrupted();//如果被唤醒,查看自己是不是被中断的。
}
这个函数在执行到park方法,线程就已经被阻塞了,但是当前面的线程释放锁之后,会唤醒当前阻塞的线程,那么线程会继续往下执行?当然没那么简单,首先需要判断一下这个线程是否已经被用户中断了,因为有些开发者会在代码中加类似于这样的逻辑:如果你等待了x秒都获取不到锁,那么你就不要再去执行了,直接中断,那么这个线程被唤醒后,实际它是一个中断状态。
那么就又引出了一个问题,通过lock()方法去获取锁的线程,如果锁被占用,线程阻塞,如果调用被阻塞线程的interrupt()方法,会取消获取锁吗?答案是否定的。LockSupport.park 会响应中断,但不会抛出 InterruptedException()(此时可以调用lockInterruptibly()方法来上锁,如果线程被中断,则会抛出InterruptedException())。也就是说,如果我们使用的是lock方法上锁,然后用interrupt去中断线程,它是不会有任何反应的,那么为什么在parkAndCheckInterrupt中还要去返回中断状态呢?直接返回void不好吗?
我们来捋一捋lockInterruptibly()方法的逻辑:
public void lockInterruptibly() throws InterruptedException {
sync.acquireInterruptibly(1);
}
public final void acquireInterruptibly(int arg) throws InterruptedException {
if (Thread.interrupted())
//如果线程已经中断,则直接抛出异常
throw new InterruptedException();
if (!tryAcquire(arg))
doAcquireInterruptibly(arg);
}
private void doAcquireInterruptibly(int arg) throws InterruptedException {
final Node node = addWaiter(Node.EXCLUSIVE);
boolean failed = true;
try {
for (;;) {
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return;
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
//这里和普通的lock加锁不一样了。
//lock加锁只是将interrupted状态记录下来
//interrupted=true;
//而这里会直接抛出异常
throw new InterruptedException();
}
} finally {
if (failed)
cancelAcquire(node);
}
}
所以,为什么parkAndCheckInterrupt()方法中不返回void,而要返回Thread.interrupted()呢?其实就是因为,在doAcquireInterruptibly中也需要调用parkAndCheckInterrupt,为了这个方法中的代码逻辑可以复用在lock加锁和lockInterruptibly中,所以就都返回了中断状态。
留个思考题吧,selfInterrupt()的作用是什么,为什么需要重新调用一次这个方法,如果不调用会有什么后果?欢迎各位读者评论区讨论。
在Java1.6之前,Synchronized是一把不折不扣的重量级锁,每一次上锁的过程都需要进行用户态和内核态的切换,而且对于没有获取到锁的线程都进行阻塞,而这时候推出的ReentrantLock就是为了解决这个痛点而存活下来的。但是反观现在,Synchronized在Java1.6之后做了大量优化,引入了自旋、自适应自旋,还将Synchronzied分出了四种状态:无锁、偏向锁、轻量级锁、重量级锁,在不同的场景下自动进行锁升级和锁降级,所以现在Synchronized已经可以摆脱其重量锁的这顶帽子了,甚至在一些场景下其效率可以超过ReentrantLock。那ReentrantLock还有存在的必要吗,显然是有的,ReentrantLock提供了丰富的API,使用起来就更加灵活,而且其提供公平锁和非公平锁的实现,是Synchronized无法做到的。
Java内存模型(Java Memory Model),本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。它和JVM的Java内存模型概念并不完全相同,可以看我的之前的博客,那上面写的是JVM的Java内存模型,由于名字相同而概念不同,所以本文中的内存模型概念统称为JMM,而JVM的Java内存模型还叫Java内存模型,JMM与Java内存划分是不同的概念层次,JMM描述的是一组规则,围绕原子性、可见性、有序性展开,其与Java内存模型唯一相似点在于都有共享区域和私有区域,JMM的共享区域即主内存,而Java内存模型的共享区域包含堆区、方法区;JMM的私有区域即工作内存,而Java内存模型的私有区域包含程序计数器、Java虚拟机栈、本地方法栈。
JVM会为每一个线程创建一个工作内存(栈空间),用于存储线程私有的数据,而Java规定所有变量都存在于主存中,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作,必须在工作内存中进行。所以每个线程首先会将变量从主内存拷贝到工作内存,操作完成后再将变量写回到主内存中。
主内存主要存储Java实例对象、包括成员变量、类信息、常量、 静态变量等。由于其属于共享区域,所以在多线程并发操作时,会引发线程安全问题。
工作内存主要存储当前方法的所有本地变量信息、字节码行号指示器、Native方法信息,本地变量对其它线程不可见。其实工作内存中存储的是主内存中变量副本的拷贝,每个线程只能访问自己的本地内存。由于工作内存是私有空间,所以不会出现线程安全问题。
1.方法里的基本数据类型本地变量将直接存储在工作内存中的栈帧结构。
2.引用类型的本地变量,引用对象存储在栈帧中,实例对象存储在主内存中。
3.实例对象的成员变量,static变量,类信息均存储在主内存中。
4.主内存共享的方式是线程各拷贝一份变量到工作内存中,操作完成后刷新回主内存。
1.单线程环境下不能改变程序运行的结果。
2.存在数据依赖关系的不允许重排序。
即:无法通过 happens-before原则推导出来的,才能进行指令的重排序。
A操作的结果需要对B操作可见,则A与B之间存在happens-before关系,例如:
i=1;//线程A执行
j=i;//线程B执行
/*由于B依赖A执行的结果,所以线程A happens-before B*/
happens-before八大原则(我也看的一脸懵):
1.程序次序原则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作。
2.锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作。
3.volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作。
4.传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C。
5.线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作。
6.线程中断规则:对线程interrupt()方法的调用先行发生于被终端线程的代码检测到中断事件的发生。
7.线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束,Thread.isAlive()的返回值手段检测到线程已终止执行。
8.对象终结规则:一个对象的初始化完成先行发生于它的finalize()方法的开始。
如果两个操作不满足上述任意一个happens-before规则,则么这两个操作就没有顺序的保障,JVM可以对这两个操作进行重排序;
如果操作A happens-before 操作B,那么操作A在内存上所做的操作对操作B都是可见的。
volatile:是JVM提供的轻量级同步机制。其可以保证被修饰的共享变量对所有线程总是可见的,还可以禁止指令的重排序,但是在多线程环境下Volatile并不保证安全性。
public class VolatileTest{
public static volatile int value = 0;
public static void main(String[] args){
inc();
}
public static void inc(){
value++;
}
}
volatile修饰的关键字value在每次操作时,其值都会即时刷新到主内存中,但是在多线程环境下,就会出现安全问题,因为value++这个操作并不具备原子性,可以使用synchronized修饰方法保证其安全问题。
当写一个volatile变量时,JMM会把该线程对应的工作内存中的共享变量值刷新到主内存中。
当读取一个volatile变量时,JMM会把该线程对应的工作内存置为无效,使其从主内存中读取。
内存屏障(Memory Barrier):保证特定操作的执行顺序,保证某些变量的内存可见性。volatile可以通过插入一条内存屏障指令,禁止在内存屏障前后的指令进行重排序优化。然后强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这个变量的最新版本。
1.volatile本质是在告诉JVM当前变量在寄存器(工作内存)中的值是不确定的,需要从内存中读取;synchronized是锁定当前变量,只有当前线程可以访问该变量,其它线程被阻塞住知道该线程完成变量操作为止。
2.volatile仅能使用在变量级别,synchronized则可以使用在方法、变量、类级别。
3.volatile仅能实现变量的修改可见性,不保证原子性。而synchronized可以保证变量修改的可见性和原子性。
4.volatile不会造成线程的阻塞,synchronized可能会造成线程的阻塞。
5.volatile标记的变量不会被编译器优化,synchronized标记的变量可以被编译器优化。
1.其支持原子更新操作,适用于计数器,序列发生器等场景。
2.属于乐观锁机制。
3.CAS操作失败时由开发者决定是继续尝试,还是执行别的操作。
CAS包含三个操作数,即 V(内存位置),E(原值),N(新值),当且仅当V==E时,才能将V改为N,否则由开发者决定重试或者执行别的操作。实际上,在JUC的Atomic系列类都使用了CAS保证变量的原子操作。在很多时候都不需要开发者直接操作CAS解决线程安全问题,而是直接使用JUC包来保证线程安全。
1.如果失败后选择重试,若循环时间长,则对性能影响较大。
2.只能保证一个共享变量的原子操作。
3.ABA问题,假设一个线程读取到一个变量的值为A,而准备进行赋值时该变量的值还是为A,就可以保证这个变量的值没有被其他线程修改过吗?如果在修改值的这段时间,这个变量的值先被另一个线程改为了B,然后又被其它线程改为了A呢?解决:AtomicStampedReference,引入版本号。
最后来聊点相对轻松的内容,乐观锁和悲观锁,就做个概念介绍吧。
总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式——CAS实现的。
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁**(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)**。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。
乐观锁适用于写比较少的情况下(多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果是多写的情况,一般会经常产生冲突,这就会导致上层应用会不断的进行retry,这样反倒是降低了性能,所以一般多写的场景下用悲观锁就比较合适。
本来这篇很早就想写,锁一直是一个很复杂的东西,总觉得写多少都不为多,所以就一直在边学习边补充内容;也有人说过我心浮气躁不应该学点东西就出来瞎写。还是那句话吧,写博客其实是为了构建自身的知识体系,也并不是想教大家一些什么东西,毕竟自己也还是个学生。所以希望各位本着相互讨论,相互学习的心态,来看这篇博客,如果有什么错误的地方,可以直接提出来大家一起讨论学习。
这篇的标题就叫《锁》 ,作为自己这一段时间对并发编程相关内容学习的一段总结。
最后祝大家国庆假期过的开心,玩得愉快。
本文图片来自网络,侵删。
欢迎大家访问我的个人博客:Object’s Blog