synchronized
synchronized是java实现线程同步的一个关键字,同步就是步调一致,synchronized修饰代码块或者函数,那么这个区域就可以看作一个同步块,不存在某一时刻多个线程同时执行同步块的代码。A线程执行完同步块。
谈到多线程,那就离不开共享变量,如果synchronized包裹的同步块中操作的净是些局部变量,那synchronized同步了个寂寞。什么时候需要同步,那肯定是多个线程并发访问同一个共享变量时才需要同步,A线程在同步块修改完这个共享变量时,B再进入这个同步块,它能立即发现共享变量的最新值,而且它修改共享变量时,不存在其他线程来捣乱的情况。
synchronized包裹的同步块是同步的,具体点:
【1】线程操作同步块代码时,是原子的(即使OS层面存在线程切换,但是java层面我们将线程访问共享变量的整套同步代码的操作看作是原子的)
【2】同步块具有可见性,线程写共享同步块内的共享变量,会使得其他线程保存该共享变量的对应缓存行失效,读共享变量则会重新从主存中去读取。
【3】synchronized块内的代码不会被重排序到synchronized块外。(synchronized同步块可以看作单线程,遵循as-if-serial,会进行重排序优化)
synchronized包的是什么?
学过操作系统都知道,进程/线程同步有很多方式,例如信号量、互斥量,其中还有一种方式就是管程。管程就像一个黑盒子,系统提供给我们使用,他能保证同一时刻只有一个进程/线程可以执行管程包裹的代码,管程为我们隐藏了实现的数据结构等细节,我们只需要关注暴露出的接口。
java程序运行在JVM之上,而JVM本质上就是对计算机的虚拟,那么java系统是否为我们也提供了管程?synchronized就是java实现的管程。
JVM层面
synchronized为用户屏蔽了实现细节,其中进入synchronized在JVM底层对应monitorEnter指令,而出synchronized对应monitorExit指令。monitor翻译过来就是管程的意思。调用synchronized方法时,编译源码后,字节码文件的方法表标识字段会出现ACC_synchronized,底层仍然会调用上面的两个指令。
同时,编译器会在以上指令附近插入内存屏障,告诉操作系统和CPU硬件,在执行该指令时禁止某些优化,来保证相应的可见性和有序性特性。
直接看以上两个指令,就感觉底层肯定有一个叫monitor的数据结构管理着同步状态。
monitor
synchronized包裹的内容可以是字符串、class对象、this(synchronized实例方法包裹的是this,而synchronized类方法包裹的是class对象)等。不管它包裹的什么,那一定是一个对象。
对象锁一般说的是synchronized(this),我创建一个resource对象,然后一堆线程争抢修改共享资源i将会被同步。而如果我再创建一个resource对象则不受影响。那是肯定的啊,this指的就是当前待被创建的实例,肯定只有操作当前实例才会出现“抢锁”啊。
class Resourse{
int i =0;
synchronized void f(){
i++;
}
}
类锁一般说的是synchronized(xxx.class)或static synchronized,那么不管通过哪个实例去操作资源类,都会被同步。因为不管class对象还是类方法都是属于类的,每个JVM实例只存在一个的,大家抢的都是这一个,和从哪里访问没有关系。
其实,如果直到了“锁”的原理,就没必要如此分析。
首先记住:synchronized关联的是monitor结构,而monitor和Object对象绑定,因此,不严谨的说,所有object对象都能作为“锁”
每个java对象在内存布局中由三部分组成:对象头、实例数据和填充数据/对齐填充。其中对象头又可以分为两部分:标记字段 mark word 和 类型指针
mark word的结构不是固定的,是动态变化的,根据结果不同可以分为无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。
如果一个对象处于重量级锁状态,那么mark word将具有一个指向重量级锁的指针。
重量级锁的创建是延迟的,而且锁升级的出现,主要原因也是为了避免重量级锁的创建。
总结:假设不存在锁升级,一旦线程初次进入synchronized块,将伴随锁(monitor)的创建,并且线程将试图获取这个锁(实现上一般是CAS将owner字段修改为某个线程id)。(重量级锁的叫法大致在,是引入锁升级之后,这里不做区分)
注意:锁本质上只是一个变量,上锁、抢锁实际含义是CAS争抢“置位操作”,用户能直接看见的是synchronize包裹着对象,其实底层线程争抢对object关联的monitor进行置位操作
从源码看synchronized
有兴趣的,可以看一看JVM对应的C++源码,这里我只进行一些个人总结。
synchronized是java对管程的一种实现,使用了某种管程模型,这里指出是为了防止固化思维。
monitor在底层,对应C++定义的objectMonitor。
每个线程都会被抽象为一个对象(类似java的thread,C++也类似,下面指的线程就是一个被抽象出的对象而不是操作系统层面的线程),每个java对象关联的monitor也是一个对象,不过是C++对象。
【1】count 记录重入次数(可重入锁的最大特点就是可以防止多次调用而导致死锁,非可重入锁通常是使用布尔值01进行标记锁的状态,而可重入锁使用一个计数器变量)
【2】owner指向拥有该对象的线程
【3】waitSet 等待队列(wait()调用后,线程被移入该队列,其实就是插入链表队尾,对应java线程的wait状态)
【4】entryList 同步队列(进入synchronized后并且没有获取到锁,则会进入该队列,对应java线程的block状态)
一个线程进入synchronized后便进行一次CAS(CAS(owner,null,cur)试图让自己称为owner),没错,这里强调的就是一次。如果第一次CAS失败则说明抢占失败,通常会进行自适应自旋(重试),如果仍然失败则进入entryList同步队列,并且调用park()阻塞当前线程,底层对应系统调用将当前线程对象映射到的操作系统线程挂起,并让出CPU,这一步通常代价比较大,因为涉及系统调用和线程切换。如果成功将owner修改为自己,则开始执行同步代码,并且将count加一。执行完毕将count减一,复位owner,并且唤起entryList阻塞的线程(实现上通常唤醒队头线程,不过如果没抢到还会进入entryList队尾,通常流动性很大,不会出现饥饿)。
而如果owner线程调用wait,则进入waitSet并阻塞(同样对应park调用),同时让出CPU。只有其他线程调用notify它才会被唤醒,而且唤醒后进入entryList,当owner被复位后,同entryList其他线程进行竞争,当称为owner将从原执行位置继续向下执行。
注意:synchronized阻塞指的通常是synchronized抢占锁失败的行为,即不管互斥锁还是自旋锁指的都是失败后的处理策略。
从操作系统看synchronized
monitor的阻塞部分底层依赖操作系统的互斥量(mutex)实现,而上锁部分则依赖CPU的CAS指令。(LockSupport/unsafe提供的park()和atomic/unsafe提供的CAS底层其实也是这一套东西,只不过拿到明面上来了)
而synchronized的可见性和有序性都是CAS保证的(lock cmpxchg),volatile的文章说的比较清楚,这里不展开了。而原子性是由锁保证的(操作同步代码之前,需要先过monitor这一关,你不是owner就别想过去)
synchronized的优化
JDK6之后对synchronized做了一些类优化:
【1】锁升级机制
【2】锁消除。一些框架采用保守策略,将程序基于线程安全实现,锁消除是一种编译器优化,通过逃逸分析消除部分无必要的同步代码。
【3】锁粗化。在编译期间将相邻的同步代码块合并成一个大的同步代码块,减少反复申请、释放造成的开销。(即使每次都可以获得锁,那么频繁的操作底层同步队列也将造成不必要的消耗)
【4】自适应自旋锁,synchronizedCAS占用owner失败后,会进行自旋尝试,这个时间不是固定的,而是前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定的
自旋锁的开启:
JDK1.6中-XX:+UseSpinning开启;
-XX:PreBlockSpin=10 为自旋次数;
JDK1.7后,去掉此参数,由jvm控制;
同时,用户也可以具有一些优化意识,如:
锁分离。最常见的就是读写分离。
减少不必要的同步代码、减少同步代码大小,减少锁的粒度(例如jdk1.8concurrentHashMap基于synchronized实现分段加锁,将粒度压缩都了每一个桶)、尽量让同步代码短小精悍,减少锁的持有时间。
锁升级
锁的升级是单向的(也不一定,和具体JVM实现有关),因为达到锁升级的条件,那么对应的场景一定是存在竞争,这时候不适合低级锁进行控制。
锁的状态取决于对象头的mark word低两位。
当对象状态为偏向锁时,mark word存储的是偏向的线程ID,当状态为轻量级锁的时候,存储的是指向线程栈中 lock record 的指针,当状态为重量级锁的时候,指向堆中monitor对象的指针。
线程在进入同步块之前,JVM会在当前线程的栈帧中创建一个锁记录 lock record,这个结构用于保存对象头mark word初始结构的复制,称为displaced mark word
其中displaced mark word用于保存对象mark word未锁定状态下的结构(用于替换——因为mark word的结构依据锁的状态不同动态变化着,因此必须有一个结构用于保存mark word的原始状态,这个结构就是保存在线程栈帧中的displaced mark word)。
lock record 总是在进入synchronized被创建,但是不同的锁类型对lock record具有不同的处理,偏向锁中lock record是空的,而轻量级锁和重量级锁中保存了lock record的地址
偏向锁
偏向锁——一段同步代码总是被一个线程所访问(不存在另外一个线程),那么该线程会自动获取锁,降低获取锁的代价。(单线程环境下都是偏向锁)
偏向锁在一个线程第一次访问的时候将该线程的id记录下来,下次判断如果还是该线程就不会加锁了。如果有另一个线程也来访问它,说明有可能出现线程并发。此时偏向锁就会升级为轻量级锁。
偏向锁的目的——在某个线程获得锁之后,消除这个线程重入(CAS)的开销,看起来让这个线程得到了偏向。
偏向锁只需要在设置thread ID时进行一次CAS操作,后续发生重入时仅仅进行简单的thread id检查,并且向线程栈帧中添加一个空的lock record表示重入,不需要CAS指令。(偏向锁一旦被某个线程获得,除非出现竞争导致撤销,否则线程不会主动释放锁即thread id只能被设定一次)
如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起(走到安全点后stop the world),JVM会消除它身上的偏向锁,将锁恢复到标准的轻量级锁。
偏向锁是对单线程场景下的优化,例如消除第三方框架同步代码带来的性能损失
轻量级锁
线程试图占用轻量级锁时,必须使用CAS指令,这是相对于偏向锁提升的开销。轻量级锁在对象头的mark word体现中,就是一个指向lock record的指针(偏向锁则是thread id)。
线程monitorenter时,栈帧中创建一个锁记录结构,然后将对象的mark word复制过去,然后使用CAS试图修改对象mark word的lock record地址值,成功则代表成功获取锁,失败则要么存在重入,或者存在竞争并通知JVM执行锁升级
注意:CAS失败的失败策略就是锁升级,不会自旋,CAS获取重锁失败后才会短暂自旋
轻量级锁适用于线程交替执行同步块的情况,如果存在同一时间访问同一锁即冲突访问的情况,就会导致轻量级锁膨胀为重量级锁。在线程总是能交替执行的场景(并发量小、同步代码执行快速),可以防止monitor对象的创建。
轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能
重量级锁
重量级锁之所以重是因为底层依赖OS的mutex互斥量实现,而依赖堆中的monitor对象(Hotspot对应objectMonitor实现)。
当然了,如果单线程下,或者不存在“竞争明显”的情况下,没有线程会被挂起,也不会出现进程切换,但是仍然需要为使用的锁对象创建绑定的monitor并且频繁CAS设置owner。用户态与内核态的切换主要是由于park()底层涉及系统调用导致的,如果CPU上下文切换的时间接近同步代码的执行时间,那么就显得效率很低下。
如果显示调用了hashCode()、notify、wait方法则会导致对象直接升级为重量级锁。
每个java对象都可以与一个监视器monitor关联,并不是一个java对象就是一个monitor对象,而是每个java对象都可以存在一个指向monitor对象的指针。
Monitor并不是随着对象的创建而创建的,而是通过synchronized告诉JVM,需要为某个java对象关联一个monitor对象。每个线程都存在两个objectMonitor对象列表,分别为free和used。当线程需要ObjectMonitor对象时,首先从线程自身的free表中申请,若存在则使用,若不存在则从global list中分配一批monitor到free中。
API细节
其实基于synchronized和JUC的Lock接口实现类使用是相似的,只不过synchronized对应的源码是C++,而Lock实现类对应的是java源码。而且注入sleep、wait等基于native修饰,由对应平台的jvm源码C++实现。
为什么wait/notify需要被同步块包裹
从实现的角度:
wait和notify依赖对象绑定的锁,只有获取锁的线程才能执行该方法(需要借助monitor关联的waitSet),否则将会抛出IllegalMonitorStateExcepti