@boothsun
2018-06-07T10:05:21.000000Z
字数 5906
阅读 3725
面试题
Synchronized的同步是基于进入和退出监视器对象(Monitor)来实现的。对于同步代码块,编译器会在自动在前后加上monitorenter和monitorexit指令定;对于同步方法,编译器会自动在方法标识上加上ACC_SYNCHRONIZED来表示这个方法为同步方法。
在JVM中,对象在内存中的布局为三块区域:对象头、实例数据和对齐填充数据。其中实例变量中主要存放类的属性数据信息,包括父类的属性信息。而对于顶部对象头又可细分为Mark Word、Class Metadata Address和Array length等。Class Metadata类型指针指向对象的类元数据,表明这个对象属于哪个类的实例。MarkWord在默认情况下存储着对象的HashCode、分代年龄、锁标记为等信息。不过32位和64位的操作系统可能每个字段的长度不同,但是都有与锁有关的:锁状态,锁标志位等。
在重量级锁情况下,锁状态为重量级锁,锁标志位10,还有一个指向Monitor对象的指针。每个对象都存在着一个monitor与之关联,当一个monitor被某个线程持有后,它便处于锁定的状态。monitor是ObjectMonitor的具体实现。
ObjectMonitor() {
_header = NULL;
_count = 0; //记录个数
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的monitor后进入_Owner区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1,若线程调用wait()方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。如下图所示:
偏向锁,顾名思义,它会偏向于第一个访问锁的线程,如果在运行过程中,同步锁只有一个线程在访问,不存在多线程争用的情况,则线程时不需要触发同步的,这种情况下,就会给同步对象加一个偏向锁。
它通过消除资源无竞争情况下的同步原语,进一步提供了程序的运行性能。
理论基础: 研究发现,大多数情况下不仅不存在锁竞争,而且总是由同一个线程多次重入,为了让同一个线程多次重入获取锁的代价更低,就引入了偏向锁的概念。(更好的支持可重入的设计)
偏向锁获取过程:
访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01,确认是否为可偏向状态。
如果为可偏向状态,则测试线程ID是否指向当前线程,如果是,进入步骤5,否则进入步骤3。
如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行5;如果竞争失败,执行4。
如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。(撤销偏向锁的时候会导致当前获得偏向锁的线程被暂停)
执行同步代码。
偏向锁的释放:
偏向锁只有当遇到其他线程也尝试获取偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。(也就是对于获取偏向锁的线程 只有lock的动作,没有unlock的动作,这是因偏向的需要,即使可能这个线程已经死亡。)偏向锁的撤销步骤如下:
加锁过程:
线程在执行同步块之前,如果此同步对象没有被锁定(锁状态标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象的目前的Mark Word的拷贝(官方称为:Displaced Mark Word)。
然后虚拟机将使用CAS操作尝试将对象头中的Mark Word更新为指向Lock Record的指针。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后两个Bits)将转变为“00”,即表示此对象处于轻量级锁定的状态。
如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了。如果有两条以上的线程争抢同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。
轻量级锁的释放过程:
轻量级解锁也是通过CAS操作来进行的,如果对象的Mark Word仍然指向这线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来,如果替换成功,整个同步过程就完成了。如果替换失败,说明有其他线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。
线程挂起和恢复都需要从用户态转为内核态,这些操作给系统的并发性能带来了很大的压力。为了应对持有锁的线程在很短时间内释放资源的场景,避免用户线程和内核的切换消耗。
自适应自旋锁的自旋时间会根据前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100个循环。另一个方面,如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。
智能化的自旋
锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除主要判定依据来源于逃逸分析的数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。
锁粗化,如果虚拟机探测到同一个线程有一连串的对同一个锁进行加锁和解锁操作,将会把加锁同步的范围扩展到整个操作序列的外部,这样就只需要加锁一次就够了。
最常见的场景是:如果有一系列的连续操作(在同一个线程中)都对同一个对象反复进行加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。
消除缓存行的伪共享:
每个CPU都有自己独占的一级缓存,二级缓存,为了提供性能,CPU读写数据是以缓存行尾最小单元读写的;32位的cpu缓存行为32字节,64位cpu的缓存行为64字节,这就导致了一些问题,例如,多个不需要同步的变量因为存储在连续的32字节或64字节里面,当需要其中的一个变量时,就将它们作为一个缓存行一起加载到某个cup-1私有的缓存中(虽然只需要一个变量,但是cpu读取会以缓存行为最小单位,将其相邻的变量一起读入),被读入cpu缓存的变量相当于是对主内存变量的一个拷贝,也相当于变相的将在同一个缓存行中的几个变量加了一把锁,这个缓存行中任何一个变量发生了变化,当cup-2需要读取这个缓存行时,就需要先将cup-1中被改变了的整个缓存行更新回主存(即使其它变量没有更改),然后cup-2才能够读取,而cup-2可能需要更改这个缓存行的变量与cpu-1已经更改的缓存行中的变量是不一样的,所以这相当于给几个毫不相关的变量加了一把同步锁;
为了防止伪共享,不同jdk版本实现方式是不一样的:
-XX:-RestrictContended
sun.misc.Contended
注解会在变量前面添加128字节的padding将当前变量与其他变量进行隔离;
不会,创建子类对象时,不会创建父类对象,其实创建子类对象的时候,JVM会为子类对象分配内存空间,并调用父类的构造函数。我们可以这样理解:创建了一个子类对象的时候,在子类对象内存中,有两份数据,一份继承自父类,一份来自子类,但是他们属于同一个对象(子类对象)。
synchronized是Java语言内置的实现,使用简单,无须关注锁的释放与获取,都是JVM自动管理。
Lock相关的锁,则需要手动获取与释放,稍有不慎,忘记释放锁或者程序处理异常导致锁没有释放,则会造成死锁。所以,通常unLock()操作都要在finally语句块中进行释放锁操作。但是,由于锁的释放与获取都由程序把控,能够实现更灵活的锁获取与释放。
1.使用Lock接口可以非阻塞地获取锁,具体API是tryLock()
和tryLock(long time, TimeUnit unit)
。
2.使用Lock接口可以响应中断和超时:synchronized
一旦尝试获取锁,就会一直等待下去,直到获取锁;但是Lock接口可以响应中断或者超时。具体API是lockInterruptibly()
和tryLock(long time, TimeUnit unit)
。
Lock接口提供了更丰富的锁语义:Lock接口及其实现类,实现了读写锁、公平锁与非公平锁等,读写锁:ReadWriteLock;公平锁与非公平锁:以ReentrantLock为例,只要构造函数传入true,就可以使用公平锁,默认是非公平锁。synchronized是支持重入的非公平锁。
Lock接口都是基于AQS实现的,锁的标志是AQS中的state标志位,当获取锁失败后,Lock会进入自旋+CAS的形式实现的,以等待锁的获取。
synchronized则是通过争抢对象关联的Monitor实现的,当线程争抢Monitor失败后,则会进入阻塞状态;由于Java的线程时映射到操作系统原生的线程之上的,如果阻塞或唤醒一个线程就需要操作系统帮忙,这时就要从用户态转到核心态,因此线程状态转换需要花费很多的处理器时间,对于简单的同步块(比如被synchronized修饰的get、set方法),往往状态转换消耗的时间比用户代码执行的时间还要长,不过随着JDK1.6后,偏向锁、轻量级锁、锁消除、自旋锁、自适应自旋锁、锁粗化的出现,synchronized的性能得到了很大的改善。
ReadWriteLock接口的实现类,可以实现读锁和写锁分离,极大提高了读多写少情况下系统性能。
从常规性能上来说,如果资源竞争不激烈,两者的性能是差不多的,但当竞争资源非常激烈时(即有大量线程同时竞争),此时Lock的性能要远远优于synchronized。但是我自己测试没有发现这两个锁有太大的性能差别。
static CyclicBarrier cyclicBarrier = new CyclicBarrier(7400) ;
static ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) throws Exception {
long startTime = System.currentTimeMillis() ;
for (int i = 0; i < 7400; i++) {
new Thread(() -> {
try {
cyclicBarrier.await();
lockTest();
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
while (Thread.activeCount() > 1) {
}
System.out.println("总共花费了:" + (System.currentTimeMillis() - startTime));
}
public synchronized static void synchronizedTest() throws InterruptedException {
Thread.sleep(5);
}
public static void lockTest() throws Exception{
try {
lock.lock();
Thread.sleep(5);
} finally {
lock.unlock();
}
}