好烦,面试官逮着我问ReentrantLock的这几个问题!

好烦,面试官逮着我问ReentrantLock的这几个问题!,第1张

之前分析AQS的时候,了解到AQS依赖于内部的两个FIFO队列来完成同步状态的管理,当线程获取锁失败的时候,会将当前线程以及等待状态等信息构造成Node对象并将其加入同步队列中,同时会阻塞当前线程。当释放锁的时候,会将首节点的next节点唤醒(head节点是虚拟节点),使其再次尝试获取锁。

同样的,如果线程因为某个条件不满足,而进行等待,则会将线程阻塞,同时将线程加入到等待队列中。当其他线程进行唤醒的时候,则会将等待队列中的线程出队加入到同步队列中使其再次获得执行权。

按照我们的分析,无论是同步队列还是等待队列都是FIFO,看起来就很公平呀?为什么ReentrankLock还分公平锁和不公平锁呢?

还是直接看源码吧,看看它是怎么做的?

首先看看锁的创建

可以看到对应不同的锁,只是代表他们内部的Sync变量不同而已。

其中NonfairSync和FairSync两个类是Sync的子类,Sync又继承自AbstractQueuedSynchronizer

当我们使用ReentrantLock加锁的时候实际上调用的是sync.lock()方法,也就是说,我们需要看看他们加锁的时候有什么不同之处?

可以看到在lock方法内部,非公平锁会先直接通过CAS修改state变量的值,如果修改成功则表示获取到了锁,而公平锁则是直接调用AQS的acquire方法来获取锁。

也就是说有可能当其他线程释放锁的时候,非公平锁能率先修改state的值成功,从而获取到锁。这样就比其他等待的线程率先获取到锁了,这就是不公平。

之前也有提到过,子类会根据自己的需求以实现tryAcquire方法,同样的非公平锁和公平锁的实现也实现了这个方法,我们可以来看看,两个的实现有什么不同

可以看到公平锁比非公平锁的实现多了一个判断条件(!hasQueuedPredecessors()),我们来看看这个方法的实现

这个方法很简单,它的意思是如果当前线程之前有排队的线程,则返回true;如果当前线程位于队列的开头或队列为空,则返回false。

也就是说公平锁在获取锁的时候会判断队列中是否已经有排队的线程,如果有则进行阻塞,如果没有则去通过CAS申请锁。

这就实现了公平锁,先来的先获取到锁,后来的后获取到锁。

所以我们可以总结下公平锁和非公平锁实现上的两点区别:

这就是两者将细微的区别,如果这非公平锁两次CAS都失败了,那么会和公平锁一样,乖乖的在同步队列中排队。

相对而言,非公平锁的吞吐量更大,但是让获取锁的时间变得不确定,可能会导致同步队列中的线程长期处于饥饿状态。

synchronized 之所以能够保证 可见性 ,是因为有一条happens-before原则,那Java SDK 里面 ReentrantLock 靠什么保证可见性呢?

它是利用了 volatile 相关的 Happens-Before 规则。AQS内部有一个 volatile 的成员变量 state,当获取锁的时候,会读写state 的值;解锁的时候,也会读写 state 的值。

这样说起来挺抽象的,我们直接去看JVM中对volatile是否有特殊的处理,在 src/hotspot/share/interpreter/bytecodeinterpreter.cpp 中,我们找到getfield和getstatic字节码执行的位置

可以看到在访问对象字段的时候,会判断它是不是volatile的,如果是,且当前CPU平台支持多核atomic *** 作(现在大多数CPU都支持),就调用 OrderAccess::fence() 。

接下来来看下Linux x86下的实现是怎样的(src/hotspot/os_cpu/linux_x86/orderAccess_linux_x86.cpp)

指令中的"addl $0,0(%%esp)"(把ESP寄存器的值加0)是一个空 *** 作,采用这个空 *** 作而不是空 *** 作指令nop是因为IA32手册规定lock前缀不允许配合nop指令使用,所以才采用加0这个空 *** 作。

而lock有如下作用

关于lock的实现有两种,一种是锁总线,一种是锁缓存。锁缓存就涉及到CPU Cache,缓存行以及MESI了,所以这里就不展开了,有兴趣的童鞋咱们可以私下交流下。

Linux系统中,实现线程同步的方式大致分为六种,其中包括:互斥锁、自旋锁、信号量、条件变量、读写锁、屏障。其中最常用的线程同步方式就是互斥锁、自旋锁、信号量。

1、互斥锁

互斥锁本质就是一个特殊的全局变量,拥有lock和unlock两种状态,unlock的互斥锁可以由某个线程获得,当互斥锁由某个线程持有后,这个互斥锁会锁上变成lock状态,此后只有该线程有权力打开该锁,其他想要获得该互斥锁的线程都会阻塞,直到互斥锁被解锁。

互斥锁的类型:

①普通锁:互斥锁默认类型。当一个线程对一个普通锁加锁以后,其余请求该锁的线程将形成一个等待队列,并在锁解锁后按照优先级获得它,这种锁类型保证了资源分配的公平性。一个线程如果对一个已经加锁的普通锁再次加锁,将引发死锁对一个已经被其他线程加锁的普通锁解锁,或者对一个已经解锁的普通锁再次解锁,将导致不可预期的后果。

②检错锁:一个线程如果对一个已经加锁的检错锁再次加锁,则加锁 *** 作返回EDEADLK对一个已经被其他线程加锁的检错锁解锁或者对一个已经解锁的检错锁再次解锁,则解锁 *** 作返回EPERM。

③嵌套锁:该锁允许一个线程在释放锁之前多次对它加锁而不发生死锁其他线程要获得这个锁,则当前锁的拥有者必须执行多次解锁 *** 作对一个已经被其他线程加锁的嵌套锁解锁,或者对一个已经解锁的嵌套锁再次解锁,则解锁 *** 作返回EPERM。

④默认锁:一个线程如果对一个已经解锁的默认锁再次加锁,或者对一个已经被其他线程加锁的默认锁解锁,或者对一个解锁的默认锁解锁,将导致不可预期的后果这种锁实现的时候可能被映射成上述三种锁之一。

2、自旋锁

自旋锁顾名思义就是一个死循环,不停的轮询,当一个线程未获得自旋锁时,不会像互斥锁一样进入阻塞休眠状态,而是不停的轮询获取锁,如果自旋锁能够很快被释放,那么性能就会很高,如果自旋锁长时间不能够被释放,甚至里面还有大量的IO阻塞,就会导致其他获取锁的线程一直空轮询,导致CPU使用率达到100%,特别CPU时间。

3、信号量

信号量是一个计数器,用于控制访问有限共享资源的线程数。

上回书说到 Linux进程的由来 和 Linux进程的创建 ,其实在同一时刻只能支持有限个进程或线程同时运行(这取决于CPU核数量,基本上一个进程对应一个CPU),在一个运行的 *** 作系统上可能运行着很多进程,如果运行的进程占据CPU的时间很长,就有可能导致其他进程饿死。为了解决这种问题, *** 作系统引入了 进程调度器 来进行进程的切换,轮流让各个进程使用CPU资源。

1)rq: 进程的运行队列( runqueue), 每个CPU对应一个 ,包含自旋锁(spinlock)、进程数量、用于公平调度的CFS信息结构、当前运行的进程描述符等。实际的进程队列用红黑树来维护(通过CFS信息结构来访问)。

2)cfs_rq: cfs调度的进程运行队列信息 ,包含红黑树的根结点、正在运行的进程指针、用于负载均衡的叶子队列等。

3)sched_entity: 把需要调度的东西抽象成调度实体 ,调度实体可以是进程、进程组、用户等。这里包含负载权重值、对应红黑树结点、 虚拟运行时vruntime 等。

4)sched_class:把 调度策略(算法)抽象成调度类 ,包含一组通用的调度 *** 作接口。接口和实现是分离,可以根据调度接口去实现不同的调度算法,使一个Linux调度程序可以有多个不同的调度策略。

1) 关闭内核抢占 ,初始化部分变量。获取当前CPU的ID号,并赋值给局部变量CPU, 使rq指向CPU对应的运行队列 。 标识当前CPU发生任务切换 ,通知RCU更新状态,如果当前CPU处于rcu_read_lock状态,当前进程将会放入rnp->blkd_tasks阻塞队列,并呈现在rnp->gp_tasks链表中。 关闭本地中断 ,获取所要保护的运行队列的自旋锁, 为查找可运行进程做准备 。

2) 检查prev的状态,更新运行队列 。如果不是可运行状态,而且在内核态没被抢占,应该从运行队列中 删除prev进程 。如果是非阻塞挂起信号,而且状态为TASK_INTER-RUPTIBLE,就把该进程的状态设置为TASK_RUNNING,并将它 插入到运行队列 。

3)task_on_rq_queued(prev) 将pre进程插入到运行队列的队尾。

4)pick_next_task 选取将要执行的next进程。

5)context_switch(rq, prev, next)进行 进程上下文切换 。

1) 该进程分配的CPU时间片用完。

2) 该进程主动放弃CPU(例如IO *** 作)。

3) 某一进程抢占CPU获得执行机会。

Linux并没有使用x86 CPU自带的任务切换机制,需要通过手工的方式实现了切换。

进程创建后在内核的数据结构为task_struct , 该结构中有掩码属性cpus_allowed,4个核的CPU可以有4位掩码,如果CPU开启超线程,有一个8位掩码,进程可以运行在掩码位设置为1的CPU上。

Linux内核API提供了两个系统调用 ,让用户可以修改和查看当前的掩码:

1) sched_setaffinity():用来修改位掩码。

2) sched_getaffinity():用来查看当前的位掩码。

在下次task被唤醒时,select_task_rq_fair根据cpu_allowed里的掩码来确定将其置于哪个CPU的运行队列,一个进程在某一时刻只能存在于一个CPU的运行队列里。

在Nginx中,使用了CPU亲和度来完成某些场景的工作:

worker_processes      4

worker_cpu_affinity 0001001001001000

上面这个配置说明了4个工作进程中的每一个和一个CPU核挂钩。如果这个内容写入Nginx的配置文件中,然后Nginx启动或者重新加载配置的时候,若worker_process是4,就会启用4个worker,然后把worker_cpu_affinity后面的4个值当作4个cpu affinity mask,分别调用ngx_setaffinity,然后就把4个worker进程分别绑定到CPU0~3上。

worker_processes      2

worker_cpu_affinity 01011010

上面这个配置则说明了两个工作进程中的每一个和2个核挂钩。


欢迎分享,转载请注明来源:内存溢出

原文地址: http://outofmemory.cn/yw/7337339.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2023-04-04
下一篇 2023-04-04

发表评论

登录后才能评论

评论列表(0条)

保存