一,锁的分类:锁从宏观上分类,分为悲观锁(synchronized)与乐观锁(偏向锁,轻量级锁,自旋锁)。
1.乐观锁:认为读多写少,并发写的可能性低。每次读数据都认为别人不会修改,不会加锁,只在更新的时候判断别人有没有更新这个数据。
使用CAS实现:在更新之前会保存一个原始值,在写时读取原始值与当前值进行比较如果相同则更新,否则失败。重复进行读—比较—写的操作。
CAS存在ABA问题:即一个线程1将A改为B,另一个线程2将B又改回A。判断的时候就不知道有没有人改过。在需要记录修改过程时一般加上版本控制。
2.悲观锁:任务写多,并发写的几率高。每次拿数据的时候都认为别人会修改,所以读写都会加上锁。获取不到锁的线程就会阻塞。
二,线程阻塞的代价
java线程是映射到操作系统原生线程上的,阻塞或唤醒一个线程需要操作系统介入,在用户态和内核态之间切换(核心态可获取内存全部数据,用户态只能获取当前用户数据,两者有各自独立的内存空间)。
用户态和内核态之间切换时向内核传递一些参数,变量,并且需要先保存用户态的一些寄存器值,变量以便再从内核态切换会用户态。
两个状态之间切换需要消耗大量cpu资源。
所以那种代码执行时间很短的代码块加锁的策略很糟。
markword
markword是java对象数据结构中的一部分,位于对象头中。java对象结构:https://blog.csdn.net/zqz_zqz/article/details/70246212
1.偏向锁
偏向锁,顾名思义,它会偏向于第一个访问锁的线程,如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的,这种情况下,就会给线程加一个偏向锁。
偏向锁获取过程:
1.访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01,确认为可偏向状态。
2.如果为可偏向状态,则测试线程ID是否指向当前线程,如果是,进入步骤5,否则进入步骤3。
3.如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行5;如果竞争失败,执行4。
4.如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。(撤销偏向锁的时候会导致stop the word)。
5.执行同步代码。
2.轻量级锁
使用轻量级锁时,不需要申请互斥量,仅仅将Mark Word中的部分字节CAS更新指向线程栈中的Lock Record,如果更新成功,则轻量级锁获取成功,记录锁状态为轻量级锁;否则,说明已经有线程获得了轻量级锁,目前发生了锁竞争(不适合继续使用轻量级锁),接下来膨胀为重量级锁。
3.自旋锁
如果当前持有锁的线程会很快释放锁,那么等待竞争锁的线程就不需要进行用户态和内核态切换进入阻塞状态,他们只需要等一等(自旋),等待持有锁的线程释放锁。
自旋:
- 当前线程竞争锁失败时,打算阻塞自己
- 不直接阻塞自己,而是自旋(空等待,比如一个空的有限for循环)一会
- 在自旋的同时重新竞争锁
- 如果自旋结束前获得了锁,那么锁获取成功;否则,自旋结束后阻塞自己
自旋就是让CPU做无用功,如果竞争一个锁的线程的太多,会导致获取说的时间变长,造成CPU资源浪费。所以需要设置自旋的时间阈值。jdk1.7以后此参数有jvm控制(自适应自旋锁:假定每个线程持有锁的时间基本相同,以上一次自旋时间调整下一次自旋时间)。
4.重量级锁synchronized
synchronized它可以把任意一个非NULL的对象当作锁。
1.作用于方法时,锁住的是对象的实例(this);
2.当作用于静态方法时,锁住的是Class实例,又因为Class的相关数据存储在永久带PermGen(jdk1.8则是metaspace),永久带是全局共享的,因此静态方法锁相当于类的一个全局锁,会锁所有调用该方法的线程;
3.synchronized作用于一个对象实例时,锁住的是所有以该对象为锁的代码块。
synchronized的实现
它有多个队列,当多个线程一起访问某个对象监视器的时候,对象监视器会将这些线程存储在不同的容器中。
1.Contention List:竞争队列,所有请求锁的线程首先被放在这个竞争队列中;
2.Entry List:Contention List中那些有资格成为候选资源的线程被移动到Entry List中;
3.Wait Set:哪些调用wait方法被阻塞的线程被放置在这里;
4.OnDeck:任意时刻,最多只有一个线程正在竞争锁资源,该线程被成为OnDeck;
5.Owner:当前已经获取到所资源的线程被称为Owner;
6.!Owner:当前释放锁的线程。
JVM每次从队列的尾部取出一个数据用于锁竞争候选者(OnDeck),但是并发情况下,ContentionList会被大量的并发线程进行CAS访问,为了降低对尾部元素的竞争,JVM会将一部分线程移动到EntryList中作为候选竞争线程。
Owner线程会在unlock时,将ContentionList中的部分线程迁移到EntryList中,并指定EntryList中的某个线程为OnDeck线程(一般是最先进去的那个线程)。Owner线程并不直接把锁传递给OnDeck线程,而是把锁竞争的权利交给OnDeck,OnDeck需要重新竞争锁。这样虽然牺牲了一些公平性,但是能极大的提升系统的吞吐量,在JVM中,也把这种选择行为称之为“竞争切换”。
OnDeck线程获取到锁资源后会变为Owner线程,而没有得到锁资源的仍然停留在EntryList中。如果Owner线程被wait方法阻塞,则转移到WaitSet队列中,直到某个时刻通过notify或者notifyAll唤醒,会重新进去EntryList中。
处于ContentionList、EntryList、WaitSet中的线程都处于阻塞状态,该阻塞是由操作系统来完成的(Linux内核下采用pthread_mutex_lock内核函数实现的)。
Synchronized是非公平锁。 Synchronized在线程进入ContentionList时,等待的线程会先尝试自旋获取锁,如果获取不到就进入ContentionList,这明显对于已经进入队列的线程是不公平的,还有一个不公平的事情就是自旋获取锁的线程还可能直接抢占OnDeck线程的锁资源。
synchronized的执行过程:
1. 检测Mark Word里面是不是当前线程的ID,如果是,表示当前线程处于偏向锁
2. 如果不是,则使用CAS将当前线程的ID替换Mard Word,如果成功则表示当前线程获得偏向锁,置偏向标志位1
3. 如果失败,则说明发生竞争,撤销偏向锁,进而升级为轻量级锁。
4. 当前线程使用CAS将对象头的Mark Word替换为锁记录指针,如果成功,当前线程获得锁
5. 如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
6. 如果自旋成功则依然处于轻量级状态。
7. 如果自旋失败,则升级为重量级锁。
上面几种锁都是JVM自己内部实现,当我们执行synchronized同步块的时候jvm会根据启用的锁和当前线程的争用情况,决定如何执行同步操作;
锁优化
1.减少锁的时间,只同步需要同步的代码,不需要的不用放在同步块里面。
2.减少锁的粒度,如jdk1.8之前的concurrentHashMap的segment,操作数据时锁住对应的segment对象,其他segment不会被锁住。
3.使用CAS
4.使用读写锁,ReentrantReadWriteLock 是一个读写锁,读操作加读锁,可以并发读,写操作使用写锁,只能单线程写;
5.读写分离,CopyOnWriteArrayList 、CopyOnWriteArraySet
CopyOnWrite容器即写时复制的容器。通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。
参考文章:https://www.cnblogs.com/linghu-java/p/8944784.html,https://www.cnblogs.com/xdyixia/p/9364247.html(synchronized的实现原理及锁优化)