Java多线程系列(6)
synchronize
定义:synchronize是Java提供的一个实现同步机制的一个关键字。它的加锁和解锁是JVM自动完成的,因此从锁的类型来说,他是一个隐式锁。
synchronize锁住的是谁
1.对象锁
锁住的是调用该方法的对象的实例。
案列:
上面这中情况锁住的是phone的这个实例对象
2.类锁
锁住的是整个class类。
案列:
上述情况锁住的是整个class类。因为静态方法属于类,当类加载的是静态方法就有了,而且只被加载一次。
synchronize的应用范围
我们知道volatile只可以对变量加锁,那么synchronize呢
1.可以在同步代码块上加锁
代码如下所示:
2.可以在方法上加锁
3.静态方法加锁
synchronize的原理
JVM内置锁通过synchronize使用加锁,synchronize的底层实现是通过每个对象在创建之初的Monitor(监视器锁)实现,是在要加锁的同步代码前面和后面各加入了一对指令,分别是进入Monitor的Monitorenter指令和释放锁的指令monitorexit指令来实现,如下图所示。而监视器锁Monitor的底层实现依赖于Mutex lock(互斥锁)实现,它是一个重量级锁,性能较低。
Monitor
每个对象在创建之初都已自己的一个monitor(监视器锁)
JVM加锁过程如下所示:
下面结合具体的代码示例来讨论:
首先三个线程同时竞争新new出来的实例对象的Monitor,如下所示,线程T1竞争成功:
此时对于线程T2和T3要放入waitset去等待T1释放锁之后再去竞争锁。
对象的内存结构
如下所示:
下面介绍Mark Word的具体内容
从上表我们可以看出Mark Word的内容会随着锁的状态的改变而改变,首先我们可以看出当Mark Word是无锁状态时,他的每一项分为25bit的对象的hashCode,还有4bit的对象分代年龄,还有1bit的标志位,标志是否是偏向锁,2bit锁标志位,表示当前锁所处于的那个状态,如果是01表示当前锁时无锁状态,如果是01也可能是偏向锁状态,此时要看是否是偏向锁这个标志位,如果是1则是偏向锁,如果是0则是无锁状态,然后依次是00轻量级锁,10重量级锁,11表示此对象已经被垃圾回收器回收。
补充问题
并不是所有的实例对象都存在堆区,有的一部分在堆区。
因为存在逃逸分析,其实,在编译期间,JIT(及时编译)会对代码做很多优化。其中有一部分优化的目的就是减少内存堆分配压力,其中一种重要的技术叫做逃逸分析。
逃逸分析(Escape Analysis)是目前Java虚拟机中比较前沿的优化技术。这是一种可以有效减少Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中,称为方法逃逸。
StringBuffer sb是一个方法内部变量,上述代码中直接将sb返回,这样这个StringBuffer有可能被其他方法所改变,这样它的作用域就不只是在方法内部,虽然它是一个局部变量,称其逃逸到了方法外部。甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。
上述代码如果想要StringBuffer sb不逃出方法,可以这样写:
不直接返回 StringBuffer,那么StringBuffer将不会逃逸出方法。
使用逃逸分析,编译器可以对代码做如下优化:
一、同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
二、将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。
将堆分配转化为栈分配:我们知道,在一般情况下,对象和数组元素的内存分配是在堆内存上进行的。但是随着JIT编译器的日渐成熟,很多优化使这种分配策略并不绝对。JIT编译器就可以在编译期间根据逃逸分析的结果,来决定是否可以将对象的内存分配从堆转化为栈。
所以,如果以后再有人问你:是不是所有的对象和数组都会在堆内存分配空间?
那么你可以告诉他:不一定,随着JIT编译器的发展,在编译期间,如果JIT经过逃逸分析,发现有些对象没有逃逸出方法(就说明此对象是属于线程私有的,那么属于线程私有的就会分配为栈内存中,因为JVM栈中的都是每个线程独有的,堆中的都是共享的),那么有可能堆内存分配会被优化成栈内存分配。但是这也并不是绝对的。就像我们前面看到的一样,在开启逃逸分析之后,也并不是所有User对象都没有在堆上分配。(参考:https://www.hollischuang.com/archives/2398)
synchronize的优化升级过程
从上图可以分析出,首先是无锁,然后是偏向锁,然后是轻量级锁,然后是重量级锁,接下来我将要分析他们的使用场景:
1.无锁:当单线程的状态下是不需要加锁的,所以是无锁状态
2.偏向锁:JVM会给第一个访问锁的线程加上偏向锁,基本没有线程竞争的同步场景。当有多个线程开始参与竞争抢占锁时,则持有偏向锁的线程会被挂起,JVM会消除他身上的锁,将锁恢复成轻量级锁。
3.轻量级锁:线程间存在交替执行的情况,线程本身竞争不是很激烈,如下所示:
上图我们发现T1线程首先获得了同步代码块的执行权,T1执行块结束的时候这是T2来了,但是T1还差一点没有执行完,对于T2,改怎么办呢?轻量级锁规定不直接去阻塞T2,而是使用自旋(执行一段无意义的循环即可),自旋不同于阻塞,自旋并不会丢弃CPU的使用权。自旋结束后看看T1有没有执行结束,如果执行结束就会获得锁,否则继续自旋,那么到底自旋多久才会结束呢?JDK1.7之后采用了自适应自旋算法,会根据上次自旋的次数来设置本次要自旋的次数,如果本次自旋还没有获得锁的使用权,自旋了好多次都没有获得锁的使用权限,那么JVM就认为你再怎么自旋也不会获得锁的使用权限,就会直接把线程阻塞。
4.重量级锁:多线程竞争的情况下,且持有锁的时间比较长,不同于轻量级锁的那种情况。
锁升级的具体过程
如果一开始只有一个线程访问锁:由无锁------》偏向锁------》轻量级锁或重量级锁的升级过程如下所示:
如上图所示当只有一个线程访问锁时,也即是线程1开始访问锁,此时获得同步对象Object的,
然后查看同步对象的Mark Word中的值,检查锁的状态和是否是偏向锁,如果无偏向锁,则使用CAS操作修改Markword中的锁的状态改为偏向锁。
并且将对象头中的Mark Word中的ThreadID指向自己,修改为如下图所示即可:
然此时有线程2来竞争锁的使用了,如下图所示:
同样此时线程2也不知道他有没有获得偏向锁,此时他也是检查对象的MarkWord,检查MarkWord中的线程ID是否是自己,如果不是就使用CAS去尝试修改Markword中线程ID为自己,如果修改失败,就说明此时有其他线程在使用,那么JVM就会把对象的偏向锁撤销,然后使用偏向锁的线程1将要被挂起,此时将锁升级为轻量级锁,如果此时线程1在偏量级锁被转为轻量级锁之前就释放锁了,那么就解锁,修改Markword中的值。
如果一开始多个线程竞争锁时的锁的升级过程:
上述是多线程竞争锁的情况。只有重量级锁才用到了monitor,其他的锁都是JVM自身去优化的。。。
为啥synchronized无法禁止指令重排,但可以保证有序性?
加了锁之后,只能有一个线程获得到了锁,获得不到锁的线程就要阻塞。所以同一时间只有一个线程执行,相当于单线程,而单线程的指令重排是没有问题的。串行访问临界资源,保证同一时刻只有一个线程访问临界资源。但是性能会下降。