前言
说到Java就必然会考虑到线程的问题,无论工作中学习中有没有直接接触过多线程开发,手写过线程调用,在这个底层已经到了多核多缓存的硬件时代,多线程是任何码农都绕不过的一个事情。只要有线程,就会有并发的现象,也同时会产生数据不一致。那么对于需要使用同一个数据的两个线程,就会产生冲突,那么就引出了锁的概念。锁有很多种本篇会针对性的说下synchronized这个关键字是如何保证线程的有序进行。更多线程知识内容请点击【Java 多线程和锁知识笔记系列】
现实场景
能用到多线程的场景有很多,比如餐馆叫号,医院挂号,银行办理业务等等数不胜数。最近春运要到了,就以买火车票为例子。现在有5个人买北京站买车票,一共有三个窗口,于是5个人去车站买票。车票么一张票对应一个座儿,谁买了谁坐,这个大家都明白。但是如果没有任何的限制,大概会有下面几个可能:
- 重号:有三个人同时去买北京到上海的票,于是三个售票员同时发现系统显示某车厢里1号座位可以卖,于是三个售票员同时给了三个人这个车厢的1号座票,这三张一样票谁用就成了麻烦,这就是重号。
- 错号:有两个人同时去买票,售票员应该卖出北京到上海给乘客1,应该卖出北京到成都给乘客2,于是按照这样的{北京上海、北京成都}的顺序提交了订单到系统。但是付钱的时候乘客2单身久了手比较快先付了钱,结果把{北京上海}的票给买到了,没办法去成都了,这个就是系统发生了错号。
- 跳号:跳号和错号发生的场景差不多。网点升级现在有50个窗口,一共有两百张票可以卖对应订单号1-200,但是有400位乘客同时去买。结果订单号生成的顺序乱七八糟,而且超过200的订单号都有可能,这种情况发生就叫做跳号。
这种问题在日常生活中是无法容忍的,因此必须使得某些变量在整个系统中是唯一,并且是线程共享的,因此java中就有了关键字static。但是static虽然可以保证唯一性,但是无法解决上面的三个问题,尤其是并发量比较大的时候。
问题分析
这种问题是如何发生的呢?简单来说就是前一个线程拿到数据做了修改,但是还没有输出就被第二个线程拿取用了。比如下图,本来应该输出101的Thread1,经过Thread2的竞争输出了102,这就是为什么产生了重号,跳号,错号等等这些问题的原因。总结来说多线程会导致数据不一致问题。
解决问题
导致这个问题的根本原因在哪呢?就是number++
这个操作,这个操作并不是一个原子操作。它可以被拆分为三步:1.读取number
;2.number+1
;3.回写主存。这些步骤都走完了才会轮到输出。所以number++
和输出应该是一个业务逻辑内的事情,也就是说在逻辑上应该是具有原子性的,不可分割的。如何对这一块逻辑进行封锁呢?Java里可以使用synchronized
关键字,当然也可以使用lock
,但是本篇的主角不是lock
。
//比如我们可以把当前对象锁起来。
synchronized (this){
while(number<100){
System.out.println("本次的号码是:"+number++);
}
}
这样锁起来以后,在执行while循环的时候,就不会在允许其他线程去干扰这部分执行,要么执行完,要么都不执行。当整个while的内容有了不可分割的属性以后,它就具有了原子性。根据这个逻辑,当Thread1占有资源时,Thread2只能等待Thread1中synchronized块里的内容运行完毕以后,才可以获取资源,以此类推。
synchronized 的锁机制
上面的解决办法,就是利用了synchronized
锁机制去实现数据同步的,从而保证了数据的一致性。从上面的例子来看,锁机制大概有两种特性:
- 排他性:也叫做互斥性、独占性,即在同一时间只允许一个线程持有某个对象锁,通过这种特性来实现多线程中的协调机制,这样在同一时间只有一个线程对需同步的代码块(复合操作)进行访问。排他性我们也往往称为操作的原子性。
- 可见性:必须确保在锁被释放之前,对共享变量所做的修改,对于随后获得该锁的另一个线程是可见的(即在获得锁时应获得最新共享变量的值),否则另一个线程可能是在本地缓存的某个副本上继续操作从而引起不一致。比如:t1修改了number为101,t2要知道number被修改了,才能对number操作继续修改为102。
synchronized 的用法
synchronized 的用法很简单,大概分为两种。可以根据修饰对象分类,也可以根据获取的锁分类。
根据修饰对象分类:
synchronized关键字修饰在方法上:
*****synchronized 可以加在非静态方法上*****
public synchronized void methodName(){
// code ......
}
*****synchronized 可以加在静态方法上*****
public synchronized static void methodName(){
// code ......
}
synchronized关键字修饰代码块:
*****synchronized 可以加在某个对象上*****
public void methodName3(){
synchronized (this){ //这里可以写任意对象,此时是当前对象
// code ......
}
}
//也可以是任意对象。但是这个对象必须是final的,因为不同的线程会创建不同的对象,也就会锁住不同的变量,因此这里就会出现不一致问题。
private final Object object =new Object();
public void methodName4(){
synchronized (object){
// code ......
}
}
*****synchronized 可以加在某个类上*****
public void methodName5(){
synchronized (SynTest.class){
// code ......
}
}
根据获取的锁分类:
获取对象锁:
就是再说下面两种修饰方法:
synchronized(this|object) {} //加在对象上
synchronized 修饰非静态方法
在 Java 中,每个对象都会有一个 monitor 对象,这个对象其实就是 Java 对象的锁,通常会被称为“内置锁”或“对象锁”。类的对象可以有多个,所以每个对象有其独立的对象锁,互不干扰。但是一旦引用类型加上final就变味了,final修饰引用类型后,在对其初始化之后便不能再让其指向其他对象了,但该引用所指向的对象的内容是可以发生变化的。这也是为什么上面说对象必须是final的。
获取类锁:
就是再说下面两种修饰方法:
synchronized(ClassName.class) {} //加在类上
synchronized 修饰静态方法
在 Java 中,针对每个类也有一个锁,可以称为“类锁”,类锁实际上是通过对象锁实现的,即类的 Class
对象锁。当ClassLoader
把class
文件加载到方法区的时候,会在堆区生产一个Class
对象,每个类只有一个 Class
对象,因此某个类的所有对象会共享同一个Class
对象。正是由于使用的是同一个Class
对象,该类所有对象都会被synchronized
所限制,所以每个类只有一个类锁。
synchronized 代码块堆栈分析
为了明白synchronized
的运行原理,首先先看下synchronized
关键字在运行时的表现,有这么一段小程序作为测试程序。
public class SyncTest {
public void method(){
synchronized(this){
try {
TimeUnit.MINUTES.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+" has started");
}
}
public static void main(String[] args) {
SyncTest test=new SyncTest();
for (int i = 0; i <5 ; i++) {
new Thread(test::method).start();
}
}
}
使用cmd调出控制台,键入jps命令显示当前运行的java线程。
找到正在运行的SyncTest
线程id:25784
,然后键入jstack 25784
,查看线程运行状况。可以看到Thread-1到Thread-4都被阻塞了,只有Thread-0 处于time wait
状态,那么就是说明synchronized
确实做到了排他性,一旦一个线程占用了某资源,其他线程执行等待资源。也正是这种状态,最终我们能够实现同步。
synchronized 代码块实现原理
synchronized
到底底层是怎么实现的呢?接下来就必须通过jvm的反编译指令进行一个分析,使用方法在最后附录里。所以我们找到SyncTest.class
文件,使用javap –v SyncTest
命令就可以把这个class
文件的附加信息都拿出来。那么我们从解析的反编译文件里找到method()
方法:
public void method();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=3, locals=4, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter ***** monitorenter互斥的入口
4: getstatic #2 // Field java/util/concurrent/TimeUnit.MINUTES:Ljava/util/concurrent/TimeUnit;
7: ldc2_w #3 // long 10l
10: invokevirtual #5 // Method java/util/concurrent/TimeUnit.sleep:(J)V
13: goto 21
16: astore_2
17: aload_2
18: invokevirtual #7 // Method java/lang/InterruptedException.printStackTrace:()V
21: getstatic #8 // Field java/lang/System.out:Ljava/io/PrintStream;
24: new #9 // class java/lang/StringBuilder
27: dup
28: invokespecial #10 // Method java/lang/StringBuilder."<init>":()V
31: invokestatic #11 // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
34: invokevirtual #12 // Method java/lang/Thread.getName:()Ljava/lang/String;
37: invokevirtual #13 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
40: ldc #14 // String has started
42: invokevirtual #13 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
45: invokevirtual #15 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
48: invokevirtual #16 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
51: aload_1
52: monitorexit ***** 一直到这里退出,可以算作互斥的出口
53: goto 61
56: astore_3
57: aload_1
58: monitorexit ***** 发现还有一个monitorexit,这个是异常出口,下面有解释
59: aload_3
60: athrow
61: return
从反编译的文件结合上述我们已经说过的锁机制,很显然汇编代码中monitorenter
就是互斥的入口,然后直到下面第52行
执行monitorexit
退出。在此期间执行的命令其他线程就无法操作了,这就是所谓的锁(Lock
),所以锁(Lock)锁住的是什么呢?锁住的就是从第3行
到第52行
之间的内容,那么整个从monitorenter
开始到monitorexit
退出就是我们常说的锁的原理。这也解释了为什么synchronized块要加在一个对象上或者需要加在一个class
上,因为synchronized
关键字需要和class文件也就是对象或者类相关联,synchronized
块就是提供这样一个monitorenter
标记给相应的class
文件,因此需要加上一个对象或者类作为标记。除此以外还有一个小要点:继续往下走,发现第58行
还有一个monitorexit
标记,为什么一个入口要有两个出口呢?因为第一个出口是正常出口,程序执行无误就从第52行
正常退出;如果程序执行发生异常,无法执行到第52行
的出口,就从第58行
的出口退出。
synchronized 作为方法关键字
上面说完synchronized
块是如何做到的锁,下面我们看看synchronized
作为方法关键字的时候会不会还是一样的,首先添加下面这样一个方法到测试程序中:
public static synchronized void methodName(){
while(number<100){
System.out.println("本次的号码是:"+number++);
}
}
然后如法炮制,运行javap –v SyncTest
命令。
public static synchronized void methodName();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED *方法标记
Code:
stack=5, locals=0, args_size=0
0: getstatic #15 // Field number:I
3: bipush 100
5: if_icmpge 44
8: getstatic #6 // Field java/lang/System.out:Ljava/io/PrintStream;
11: new #7 // class java/lang/StringBuilder
14: dup
15: invokespecial #8 // Method java/lang/StringBuilder."<init>":()V
18: ldc #16 // String 本次的号码是:
20: invokevirtual #11 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
23: getstatic #15 // Field number:I
26: dup
27: iconst_1
28: iadd
29: putstatic #15 // Field number:I
32: invokevirtual #17 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
35: invokevirtual #13 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
38: invokevirtual #14 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
41: goto 0
44: return
LineNumberTable:
line 19: 0
line 20: 8
line 22: 44
StackMapTable: number_of_entries = 2
frame_type = 0 /* same */
frame_type = 43 /* same */
发现这次执行后,从上到下整个方法都没有monitorenter
这个作为入口的关键字。虽然没有找到但是我们可以在方法最上面找到这样一行标记:flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
。仔细观察这行标记,其实就是我们写在方法前面的修饰词:public
、 static
和synchronized
。其实ACC_SYNCHRONIZED
就是互斥开始标记,一旦发现这个标记就表示这个方法是一个同步方法,或者叫互斥方法。当一个线程执行含有ACC_SYNCHRONIZED
标记的方法的时候,别的线程就无法调用这个方法。这个就是synchronized
关键字对方法加锁的原理。
总结
到此synchronized
的基本原理和使用方法就告一段落了。本篇通过实例讲解了synchronized
的场景和synchronized
的使用方法,通过反编译class
字节码文件以后的输出,分析出synchronized
方法是用的ACC_SYNCHRONIZED
信号作为互斥标记,而synchronized
块则是用的monitorenter
和monitorexit
作为互斥标记,两种不同的机制去实现同步。但是某一块代码一旦被加上synchronized关键字以后,相当于对其他线程锁住了,因此synchronized
算是一个相当重量级的锁。因此使用synchronized
需要注意一些问题:
- 与moniter关联的对象不能为空,也就是说synchronized参数不能为null。
- synchronized作用域太大,导致程序里有大量不必要的单线程执行过程,而不是多线程执行。
- 不同的monitor企图锁相同的方法,结果多个锁的交叉导致出现:A线程等B线程释放资源,B线程等A线程释放资源的死锁问题。
正是由于有这些问题,为了提高synchronized
可用性,Java官方针对synchronized
关键字也进行了优化,下篇【Java 线程知识笔记 (六) 锁与锁的状态升级】就会从synchronized
优化引出锁这一概念,并且对锁的原理和升级进行讲解。
附:如何找到并使用Java反编译文件
首先进入项目路径的/out
路径,找到.class
文件:
然后呼叫出控制台,进入当前目录:
输入命令javap –v [字节码名称]打印额外信息。
javap –v 命令说明: