1:问题
举例引出问题
public class test {
static int count = 0;
public static void main(String[] args) throws InterruptedException {
Runnable runnable = new Runnable() {
@Override
public void run() {
add();
}
};
Thread thread = new Thread(runnable);
Thread thread1 = new Thread(runnable);
thread.start();
thread1.start();
thread.join();
thread1.join();
System.out.println("count:" + count);
}
public static void add() {
for (int i = 0; i < 5000; i++) {
count++;
}
}
}
上述代码中,启动两个线程,对变量count进行数值操作,两个线程分别执行5000次,按照预期结果来看,count的最终结果应该是10000,但是实际得到的数值却不是10000,并且实际得到的数值不是确定的。
那么为什么会出现这种情况呢?
因为JAVA中的++操作,它在语言层面虽然只是一行代码,看似是原子操作的,但是站在字节码的角度,它却不是原子操作的,所以两个线程在对count进行数值操作时,会出现线程之间的交错运行,导致count达不到预期的结果。
为什么线程之间交错运行就会导致count达不到预期结果呢?
因为两个线程进行计算的变量是属于共享变量,而不是线程私有的变量,所以两个线程进行计算时会出现一个线程将count复制到自己的空间进行计算了但是还没赋值给count,此时另一个线程读取count进行计算赋值,这种情况会导致count最终结果达不到预期。
如何解决这种情况?
由于count++操作在字节码的角度不是原子操作的,我们可以站在宏观的角度去解决,JAVA提供了synchronized关键字来帮助我们保证一组代码在执行时不会有别的线程来执行,这样子就可以保证在进行count++操作时只有一个线程在执行。
2:synchronized的使用方法
synchronized的使用可以作用于代码块或者是方法上
public class test {
public void function(){
synchronized (锁对象){
......
}
}
public synchronized void function1(){
......
}
}
在使用synchronized时要分清楚锁对象是谁,线程间所用的锁对象是否是同一个,如果线程间使用的锁对象不是同一个,则synchronized其实是没有意义的。
synchronized (this){
// 当前对象
}
Object lock = new Object();
synchronized (lock){
// lock对象
}
synchronized (XXX.class){
// XXX类
}
public synchronized void function(){
// 当前对象
}
public static synchronized void function(){
// 当前类
}
总结:
- 对于普通同步方法,锁就是当前实例
- 对于静态同步方法,锁就是当前类的Class对象
- 对于同步方法块,锁就是synchronized里配置的对象
3:synchronized原理
知道了synchronized的基本用法,synchronized需要与一个类或者对象进行关联,可以把类或者对象理解为一把锁,那么底层是怎么实现的呢?
在JVM规范中提到了synchronized在JVM中的实现原理,JVM是基于进入和退出Monitor对象来实现方法同步和代码块同步,但是两者的实现不一样。代码块同步是使用monitorenter和monitorexit指令实现的,而方法同步是使用另外一种方式实现的,方法同步的细节,JVM规范中没有详细说明,但是方法同步也是使用这两个指令进行实现的。
首先编译这段代码查看字节码
可以看到代码块同步中,monitorenter是在代码同步块开始的地方,monitorexit是在代码块方法结束的地方和异常处。
接下来查看同步方法的字节码
可以看到代码块同步可以看到monitorenter与monitorexit指令,但是方法块同步在编译后却没有看到。相关书籍中说明同步方法是使用另外一种方式进行实现的,但是本质都是使用monitorenter与monitorexit指令来进行实现。
4:Java对象头
synchronized用的锁是存在于java对象头里的。如果对象是数组类型,则虚拟机用3个字宽(Word)存储对象头,如果对象是非数组类型的,则用2个字宽来存储对象头。在32位虚拟机中,一个字宽等于4个字节,即32bit。
java对象头中的Mark Word里默认存储对象的HashCode、分代年龄、锁标记。32位的JVM的Mark Word默认存储如图所示。
在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化。
上图中可以看到轻量级,重量级,偏向锁,GC与正常状态下的Mark Word的存储内容
5:轻量级锁
轻量级锁的使用场景:如果一个对象虽然有多线程要加锁,但加锁的时间是错开的(也就是没有竞争),那么可以
使用轻量级锁。
5.1:轻量级锁加锁:
线程执行同步代码块之前,JVM会现在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头的Mark Word信息复制到锁记录中,官方称之为 Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,则当前线程获取到锁,如果失败则表示则表示有其他线程再竞争锁,当前线程尝试使用自旋来获取锁,当然 自旋已经是属于重量级锁的优化了。
上图是获取到轻量级锁的示意图,当前线程存储了对象头的Mark Word的信息,且CAS替换了对象头中的Mark Word,替换为指向锁记录的指针。
可重入锁的示意图,常见的情况就是一个线程执行A方法获取XXX锁成功,然后A方法中调用B方法,B方法中也需要获取XXX锁,由于当前线程已经在执行A方法时获取到XXX锁了,此时调用B方法获取XXX锁时也会在栈帧中创建一条锁记录。
5.2:轻量级锁解锁:
轻量级锁解锁时,会使用CAS操作将Displaced Mark Word替换回对象头中,如果成功,则表示没有发生竞争,如果失败了,则表示当前锁存在竞争,锁就会膨胀为重量级锁,进行重量级锁的解锁操作。
上图中,Thread-0已经获取到了轻量级锁,此时Thread-1尝试获取锁对象,进行了Displaced Mark Word操作后进行CAS尝试替换对象头的Mark Word来指向Thread-1时会失败,因为此时的对象头的Mark Word信息指向了Thread-0,此时Thread-1会尝试 自旋 重试来获取锁,一定次数后如果获取不到锁,则会进行锁膨胀,将对象头(Object)申请Monitor锁,使对象头的Mark Word指向Monitor对象的地址,Monitor的Owner指向Thread-0,同时Thread-1进行Monitor的EntryList进行睡眠。
当Thread-0解锁后,会发现此时的锁已经不是轻量级锁,而升级位重量级锁,Thread-0会重置Monitor的Owner并唤醒EntryList中的线程来竞争锁。
-
自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
-
在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会
高,就多自旋几次;反之,就少自旋甚至不自旋。 -
Java 7 之后不能控制是否开启自旋功能
-
因为自旋是会消耗CPU资源的,为了避免无用的自旋,(比如获得锁的线程被阻塞住了),一旦锁升级为重量级锁,就不会再恢复到轻量级锁的状态,当锁处于这个状态下,其他线程尝试获取锁时,都会被阻塞住进入EntryList中,当持有锁的线程释放锁时会唤醒这些线程,被唤醒的线程开始锁的竞争。
6:偏向锁
HotSopt的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获得,为了让线程获取锁的代价更低而引入了偏向锁。当一个线程访问同步代码块并获取锁时,会在对象头和栈帧中的锁记录里存放锁偏向的线程ID,以后该线程在进入和退出同步代码块时不需要使用CAS操作来进行加锁和解锁,只需要简单的测试对象头的Mark Word中是否存储着指向当前线程的偏向锁,如果测试成功,则表示当前线程已经获取到锁了,如果失败了,则需要再次测试Mark Word中的偏向锁的标识是否设置为了1(表示当前是偏向锁),如果没有设置,则使用CAS来竞争(此时就是轻量级锁或者重量级锁了),如果设置了,则尝试将对象头的偏向锁指向当前线程。
关于轻量级锁,重量级锁,偏向锁,GC和正常状态下的Mark Word的格式,上文已经描述。
接下来使用jol来查看对象的Mark Word信息
<dependency>