小伙伴大家好,我是jack xu,今天是清明假期,跟大家来聊一聊synchronized。本篇是并发编程中的第一篇,为什么说是第一篇呢,因为并发编程涉及的东西太多太多,晦涩难懂,随便一个知识点拉出来都可以写一篇文章,如此算来写完并发编程一个系列最起码要十篇。我将知识点进行了总结归纳,排类分类,用通俗易懂的方式来跟大家说清楚、讲明白。。
这个问题很简单,首先我们来看下面这个代码
开10000个线程,将变量count递增,结果是9998,很显然是出现了线程不安全。那为什么会出现这样的结果呢,答案也很简单
这里稍微解释下为啥会得不到 100(知道的可直接跳过), i++ 这个操作,计算机需要分成三步来执行。 1、读取 i 的值。 2、把 i 加 1. 3、把 最终 i 的结果写入内存之中。 所以,(1)、假如线程 A 读取了 i 的值为 i = 0,(2)、这个时候线程 B 也读取了 i 的值 i = 0。 (3)、接着 A把 i 加 1,然后写入内存,此时 i = 1。(4)、紧接着,B也把 i 加 1,此时线程B中的 i = 1, 然后线程B 把 i 写入内存,此时内存中的 i = 1。也就是说,线程 A, B 都对 i 进行了自增,但最终的结果却是1,不是 2. 复制代码
归根到底一句话就是这么多操作不是原子性,那怎么解决这个问题呢,加上Synchronized即可
在上面例子演示的是原子性。synchronized 可以确保可见性,根据happens-before规定,在一个线程执行完 synchronized 代码后,所有代码中对变量值的变化都能立即被其它线程所看到。顺序性的话就是禁止指令重排,代码块中的代码从上往下依次执行,归根到底再一句话,并发问题中的三个特性synchronized都能保证,也就是synchronized是万金油,用他准没错!
从语法上讲,Synchronized总共有三种用法:
public synchronized void eat(){ ....... ....... } 复制代码
public static synchronized void eat(){ ....... ....... } 复制代码
public void eat(){ synchronized(this){ ....... ....... } } 复制代码
public void eat(){ synchronized(Eat.class){ ....... ....... } } 复制代码
其中第一种和第三种对等,第二种和第四种对等,这个很简单,下面是使用 synchronized的总结:
好,本文的高潮来了,大家仔细听,在JDK的早期,synchronized叫做重量级锁,因为申请锁资源必须通过kernel,系统调用,从用户态 -> 内核态的转换,效率比较低,JDK1.6 之后做了一些优化,为了减少获得锁和释放锁带来的性能开销,引入了偏向锁、轻量级锁的概念。因此大家会发现在 synchronized 中,锁存在四种状态分别是:无锁、偏向锁、轻量级锁、重量级锁;
我们知道synchronized锁的是对象,对象就是Object,Object在heap中的布局,如下图所示
前面8个字节就是markword,后面4个字节是class pointer就是这个对象属于哪个类的,People就是People.class,Cat类就是Cat.class,在后面实例数据就是看你类里面字段的具体大小了,int age就是4个字节,string name就是英文1个字节, 中文2个字节(String的中文字节数要看用的编码集合,如果是utf-8类型的,那么中文占2到3个字节,如果是GBK类型的,那么中文占2个字节),最后前面三项加起来不能被8整除的,就是补齐到能够被8整除。下图就是markword(8*8=64位)的分布图,锁升级就是markdown里面标志位的变化。
网上所以的图都是32位的,我这里画的是64位的,大家发现一共有五种状态,用两位是不够的,所以01的时候在向前借一位。
hotspot虚拟机的作者经过调查发现,大部分情况下,加锁的代码不仅仅不存在多线程竞争,而且总是由同一个线程多次获得。所以基于这样一个概率,我们一开始加锁上的是偏向锁,当一个线程访问加了同步锁的代码块时,首先会尝试通过CAS操作在对象头中存储当前线程的ID
(1)如果成功markword则存储当前线程ID,接着执行同步代码块
(2)如果是同一个线程加锁的时候,不需要争用,只需要判断线程指针是否同一个,可直接执行同步代码块
(3)如果有其他线程已经获得了偏向锁,这种情况说明当前锁存在竞争,需要撤销已获得偏向锁的线程,并且把它持有的锁升级为轻量级锁(这个操作需要等到全局安全点,也就是没有线程在执行字节码)才能执行
在我们的应用开发中,绝大部分情况下一定会存在 2 个以上的线程竞争,那么如果开启偏向锁,反而会提升获取锁的资源消耗。所以可以通过jvm参数UseBiasedLocking 来设置开启或关闭偏向锁
撤销偏向锁,升级轻量级锁,每个线程在自己的线程栈生成LockRecord,用CAS操作将markword设置为指向自己这个线程的LR的指针,设置成功者得到锁。 轻量级锁在加锁过程中,用到了自旋锁,自旋锁的使用,其实也是有一定条件的,如果一个线程执行同步代码块的时间很长,那么这个线程不断的循环反而会消耗 CPU 资源。
(1)默认情况下自旋的次数是 10 次,可以通过-XX:PreBlockSpin来修改,或者自旋线程数超过CPU核数的一半
(2)在 JDK1.6 之后,引入了自适应自旋锁,自适应意味着自旋的次数不是固定不变的,而是根据前一次在同一个锁上自旋的时间以及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源
满足这两种情况之一后升级为重量级锁
这时候就惊动老佛爷了,向操作系统申请资源,linux mutex , CPU从3级-0级系统调用,线程挂起,进入等待队列,等待操作系统的调度,然后再映射回用户空间。
我们随便写一段简单的带有 synchronized 关键字的代码。先将其编译为.class 文件,然后使用 javap -c xxx.class 进行反汇编。我们就可以得到 java 代码对应的汇编指令。里面可以找到如下两行指令。
字节码层面就是关键的这两条指令,monitorenter,moniterexit (注:代码块用的是ACC_SYNCHRONIZED,这是一个标志位,底层原理还是这两条指令)
java中每个对象都关联了一个监视器锁monitor,当monitor被占用时就会处于锁定状态。线程执行monitorenter 指令时尝试获取monitor的所有权,过程如下:
从上面过程可以看出两点, 第一:monitor是可重入的,他有计数器,第二:monitor是非公平锁
monitor 依赖操作系统的mutexLock(互斥锁)来实现的,线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能
我们都知道 StringBuffer 是线程安全的,因为它的关键方法都是被 synchronized修饰过的,但我们看上面这段代码,我们会发现,sb 这个引用只会在 add方法中使用,不可能被其它线程引用(因为是局部变量,栈私有),因此 sb是不可能共享的资源,JVM 会自动消除 StringBuffer 对象内部的锁。
public void add(String str1,String str2){ StringBuffer sb = new StringBuffer(); sb.append(str1).append(str2); } 复制代码
好,本文对synchronized所涵盖的知识点已经讲解的很清楚了。synchronized是Java并发编程中最常用的用于保证线程安全的方式,其使用相对也比较简单。在synchronized优化以前,synchronized的性能是比ReentrantLock差很多的,但是自从synchronized引入了偏向锁,轻量级锁(自旋锁)后,两者的性能就差不多了。 在两种方法都可用的情况下,官方甚至建议使用synchronized,其实synchronized的优化我感觉就借鉴了ReentrantLock中的CAS技术。都是试图在用户态就把加锁问题解决,避免进入内核态的线程阻塞。