文章目录
- 前言
- 正文
- 一、多线程操作同一数据时的问题
- 二、问题分析
- 三、synchronized 解决问题
- 四、synchronized 是怎么解决问题的
- 五、Java1.6时的优化
- 5.1 自旋锁
- 5.2 自适应锁
- 5.3 锁消除
- 5.4 锁粗化
- 5.5 偏向锁(单线程高效场景)
- 5.2 轻量级锁(多线程交替时)
前言
Java 中有这样一个关键字,它挑起了Java锁的半边天。甚至于在一些Java面试中,也会经常被提出来讨论。它就是 synchronized
,今天我们就一起来看看它在Java锁中起到了什么样的作用把!
正文
一、多线程操作同一数据时的问题
首先来看一个简单的例子,多线程场景对一个对象中的某个属性进行操作。定义一个Demo类,并且增加属性 num
,如下类图:
然后开启多个线程对Demo中的 num属性进行操作(增加或减少)。这里开启100个线程,对同一个Demo实例中的num进行自增,具体代码:
import java.util.concurrent.TimeUnit;
public class Demo {
private Integer num = 0;
public static void main(String[] args) throws InterruptedException {
Demo demo = new Demo();
// 开启线程执行任务
for (int i = 0; i < 100; i++) {
new Thread(() -> {
demo.num++;
}).start();
}
// 等待线程执行完毕
TimeUnit.SECONDS.sleep(1);
// 输出结果
System.out.println(demo.num);
}
}
分析以上代码,我们可以看到,正常情况下num值在最终会自增到100,但是实际情况却是到不了100。
我在这里执行了多次,最终打印的结果有96
,98
等。甚至于你的电脑如果跑的更快的话,这个值还能更小(距离100会更远)。
这里的原因还是比较好理解的,主要是多线程操作了同一对象中的同一属性,发生了数据不同步的问题。
这里调整一下线程里的执行代码:
import java.util.concurrent.TimeUnit;
public class Demo {
private Integer num = 0;
public static void main(String[] args) throws InterruptedException {
Demo demo = new Demo();
for (int i = 0; i < 20; i++) {
new Thread(() -> {
demo.num++;
System.out.println(Thread.currentThread().getName() + "执行num计算,值:" + demo.num);
}, "thread-" + i).start();
}
TimeUnit.SECONDS.sleep(1);
System.out.println(demo.num);
}
}
现在我们创建20个线程对数据进行计算,并且打印出线程名和计算时的num的值。
thread-19执行num计算,值:16
thread-9执行num计算,值:4
thread-6执行num计算,值:12
thread-7执行num计算,值:6
thread-14执行num计算,值:10
thread-18执行num计算,值:17
thread-15执行num计算,值:15
thread-17执行num计算,值:19
thread-12执行num计算,值:14
thread-11执行num计算,值:11
thread-4执行num计算,值:7
thread-2执行num计算,值:2
thread-8执行num计算,值:5
thread-1执行num计算,值:13
thread-16执行num计算,值:18
thread-13执行num计算,值:9
thread-0执行num计算,值:1
thread-10执行num计算,值:3
thread-3执行num计算,值:8
thread-5执行num计算,值:7
19
可以观察到thread-4 和 thread-5的计算结果是相同的,也就是说他们哥俩在获取num的原值的时候,拿到了相同的原值,因此导致了现在的情况。
二、问题分析
首先我们依据JMM模型对以上数据变化进行分析:
线程会从主内存拿到共享变量的值,然后在自己的工作内存保存一份副本,最终运行的结果也是使用的这个副本。
多线程场景下,这样就会出现问题。
- 场景1:线程1拿到共享变量,并执行一些计算,随后将共享变量的值写回给主内存,在这之后,线程2才开始跑,线程2从主内存拿到了线程1更新后的结果,然后开始自己的计算。
- 场景2:线程1拿到共享变量,还没开始计算(或者没执行到写回数据给主内存的时候),线程2也开始跑,线程2从主内存拿到数据(没改过的数据),也开始进行自己的计算,最终无论他们谁先写回数据,最终的结果也是有问题的。
场景2就是我们现在遇到的情况了。
三、synchronized 解决问题
synchronized 关键字的重要作用:一个变量在同一时刻只能被一个线程对其进行lock操作,串行操作。
在原有代码中加入同步代码块:
import java.util.concurrent.TimeUnit;
public class Demo {
private Integer num = 0;
public static void main(String[] args) throws InterruptedException {
Demo demo = new Demo();
for (int i = 0; i < 20; i++) {
new Thread(() -> {
synchronized (demo){
demo.num++;
System.out.println(Thread.currentThread().getName() + "执行num计算,值:" + demo.num);
}
}, "thread-" + i).start();
}
TimeUnit.SECONDS.sleep(1);
System.out.println(demo.num);
}
}
运行结果发生了变化,每次执行都是相同的结果:
thread-0执行num计算,值:1
thread-11执行num计算,值:2
thread-7执行num计算,值:3
thread-2执行num计算,值:4
thread-5执行num计算,值:5
thread-4执行num计算,值:6
thread-6执行num计算,值:7
thread-8执行num计算,值:8
thread-1执行num计算,值:9
thread-10执行num计算,值:10
thread-12执行num计算,值:11
thread-13执行num计算,值:12
thread-14执行num计算,值:13
thread-3执行num计算,值:14
thread-15执行num计算,值:15
thread-19执行num计算,值:16
thread-17执行num计算,值:17
thread-18执行num计算,值:18
thread-16执行num计算,值:19
thread-9执行num计算,值:20
20
四、synchronized 是怎么解决问题的
我们使用 javap 命令查看一下编译后的内容,命令及参数如下:javap -c -v -p Demo.class
。在命令行打出来的结果是包含如下内容:
private static void lambda$main$0(Demo);
descriptor: (LDemo;)V
flags: (0x100a) ACC_PRIVATE, ACC_STATIC, ACC_SYNTHETIC
Code:
stack=3, locals=5, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: aload_0
5: getfield #3 // Field num:Ljava/lang/Integer;
8: astore_2
9: aload_0
10: aload_0
11: getfield #3 // Field num:Ljava/lang/Integer;
14: invokevirtual #15 // Method java/lang/Integer.intValue:()I
17: iconst_1
18: iadd
19: invokestatic #2 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
22: dup_x1
23: putfield #3 // Field num:Ljava/lang/Integer;
26: astore_3
27: aload_2
28: pop
29: getstatic #13 // Field java/lang/System.out:Ljava/io/PrintStream;
32: invokestatic #16 // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
35: invokevirtual #17 // Method java/lang/Thread.getName:()Ljava/lang/String;
38: aload_0
39: getfield #3 // Field num:Ljava/lang/Integer;
42: invokedynamic #18, 0 // InvokeDynamic #2:makeConcatWithConstants:(Ljava/lang/String;Ljava/lang/Integer;)Ljava/lang/String;
47: invokevirtual #19 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
50: aload_1
51: monitorexit
52: goto 62
55: astore 4
57: aload_1
58: monitorexit
59: aload 4
61: athrow
62: return
Exception table:
from to target type
4 52 55 any
55 59 55 any
LineNumberTable:
line 11: 0
line 12: 4
line 13: 29
line 14: 50
line 15: 62
LocalVariableTable:
Start Length Slot Name Signature
0 63 0 demo LDemo;
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 55
locals = [ class Demo, class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 6
}
其中很关键的两个指令是 monitorenter 和 monitorexit 。所有java对象都有一个monitor监视器对象存储该对象的对象头中,我这里的Demo对象也是如此。
特别注意的是,synchronized 定义为同步方法时,是使用ACC_SYNCHRONIZED进行标识,然后隐式使用了监视器指令。
monitorenter 和 monitorexit 分别表示获取和释放 monitor ,如果使用monitorenter 进入时monitor为0,表示获取到了锁,也就是当前线程可以持有monitor后续的代码,并且将monitor 加1;如果当前已经加过1,就再加1依次类推,这也是“可重入锁”的一个体现。
如果其他线程进来时,发现monitor不是0,那就得阻塞。
同样的道理,monitorexit 时会减1,直到最终减为0,表示释放了锁,其他线程才能去争抢执行。
五、Java1.6时的优化
那如果我在共享资源没有被多线程竞争时,仍然使用了同步代码块,岂不是加锁,影响了效率呢?还有就是多线程竞争时还有没有别的效率优化措施。
这一点Java开发人员已经想到了,在Jdk1.6版本时对synchronized进行了优化,提出了偏向锁,轻量级锁,重量级锁,锁升级和锁降级等概念。
这里有必要提几个概念!以下内容划重点。
5.1 自旋锁
由于大部分时候,锁被占用的时间很短,共享变量的锁定时间也很短,所有没有必要挂起线程,用户态和内核态的来回上下文切换严重影响性能。
自旋的概念就是让线程执行一个忙循环,可以理解为就是啥也不干,防止从用户态转入内核态,自旋锁可以通过设置-XX:+UseSpining来开启,自旋的默认次数是10次,可以使用-XX:PreBlockSpin设置。
5.2 自适应锁
自适应锁就是自适应的自旋锁,自旋的时间不是固定时间,而是由前一次在同一个锁上的自旋时间和锁的持有者状态来决定。
5.3 锁消除
锁消除指的是JVM检测到一些同步的代码块,完全不存在数据竞争的场景,也就是不需要加锁,就会进行锁消除。
5.4 锁粗化
锁粗化指的是有很多操作都是对同一个对象进行加锁,就会把锁的同步范围扩展到整个操作序列之外。
5.5 偏向锁(单线程高效场景)
JVM利用CAS
在对象头上设置线程ID表示这个对象偏向于当前线程。
也就是说,这个锁会偏向于上一个获取过它的线程。
应用场景适合单线程的情况。减少了获取锁的开销。主要优点在于,在只有一个线程执行同步块时进一步提高性能,适用于一个线程反复获得同一锁的情况。偏向锁可以提高带有同步但无竞争的程序性能。
当有其他线程竞争偏向锁时,持有偏向锁的线程就会释放偏向锁。可以用过设置
-XX:+UseBiasedLocking
开启偏向锁
5.2 轻量级锁(多线程交替时)
在我们开发程序过程中,多线程交替执行的场景无疑是最多的。在概念上,前面我们提到的 monitor 实现的锁叫做重量级锁。
轻量级锁是在多线程交替或者偏向锁撤销(比如偏向锁的那个线程被暂停)时,会升级为轻量级锁。主要目的是为了减少重量级锁引起的性能消耗。
对象的对象头中包含有一些锁的标志位,代码进入同步块的时候,JVM将会使用CAS方式来尝试获取锁,如果更新成功则会把对象头中的状态位标记为轻量级锁,如果更新失败,当前线程就尝试自旋来获得锁。
以下流程图简要说明了锁升级的各个阶段。