1. 线程安全问题
在进行多线程编程的时候,有可能会出现多个线程同时访问同一资源的情况,这种资源可以是变量,对象,文件,数据库表等,这时候就有可能出现最终访问结果不一致的情况。来举一个最简单的例子,下单与库存的问题:
- 下单的时候,先获取剩余库存;
- 如果还有库存,下单成功,库存减1,如果没有库存,下单失败;
如果线程thread-1和线程thread-2,同一时刻,都读取到库存还剩1,然后两个线程都执行下单成功,这时就会出现库存超卖的情况。
这其实就是一种线程安全问题,也就是多个线程访问同一资源时,会导致程序的运行结果并不是我们期望的结果。而这里的资源被称为临界资源或共享资源,临界资源可以时一个对象,对象中的属性,一个文件,一个数据库等,不过方法内部的局部变量并不是临界资源,因为方法是在栈上执行的,而Java栈是线程私有的。
2. synchronized同步代码块
基本上所有的并发模式在解决线程安全问题时,都是采用的序列化访问临界资源
的方案,也就是同一时刻,只能有一个线程访问临界资源,也称为同步互斥访问。通常实现就是对临界资源加一个锁,当访问完临界资源后释放锁,让其他线程访问,而synchronized关键字就是其中的一种实现方式。
synchronized,同步代码块,是Java内置的一种锁机制,其中包含了两部分,一部分是锁的对象引用,另一部分是锁保护的代码块。其中该同步代码块的锁就是方法调用所在的对象,而静态的synchronized方法的锁是class对象。
首先,我们要知道每个对象都有一个内部锁,这些锁被称为内置锁(Intrinsic Lock)或者监视锁(Monitor Lock),线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁。并且该锁有一个内部条件,由锁来管理那些试图进入synchronized方法的线程,由条件来管理那些调用wait的线程。所以在多线程中要访问某个对象前,必须要获取了该对象的锁才能访问。
Java中,synchronized关键字用于同步代码块(变量或者类名)和方法(实例方法或静态方法),当某个线程调用synchronized所修饰的方法或代码块时,这个线程便获得了该对象的锁,其他线程只能处于等待或阻塞中,等该线程执行代码块完成释放锁后才能执行。
2.1 synchronized修饰方法
我们先来看一个简单的例子:
public class ThreadTest {
public static void main(String[] args) {
final Test test = new Test();
new Thread(() -> test.test(Thread.currentThread())).start();
new Thread(() -> test.test(Thread.currentThread())).start();
}
}
class Test {
void test(Thread thread) {
for (int i = 0; i < 10; i++) {
System.out.println(thread.getName() + ":" + i);
}
}
}
首先,我们不使用synchronized关键字,查看下打印结果(截取部分):
Thread-0:0
Thread-0:1
Thread-0:2
Thread-0:3
Thread-1:0
Thread-1:1
Thread-1:2
Thread-0:4
Thread-1:3
Thread-0:5
可以看到,两个线程在同时执行test方法,然后我们给test方法添加synchronized参数,再次查看下结果:
Thread-0:0
Thread-0:1
Thread-0:2
Thread-0:3
Thread-0:4
Thread-0:5
Thread-0:6
Thread-0:7
Thread-0:8
Thread-0:9
Thread-1:0
Thread-1:1
可以看到,Thread-1是等Thread-0插入完成之后才进行的,它们之间是一种顺序执行的关系。
不过可能需要注意下:
当一个线程正在访问一个对象的synchronized方法,那么其他线程不能访问该对象的其他synchronized方法,但是可以访问该对象的非synchronized方法。这个原因很简单,因为一个对象只有一把锁,当一个线程获取了该对象的锁之后,其他线程无法获取该对象的锁,所以无法访问该对象的其他synchronized方法,而访问非synchronized方法是不需要获取对象锁的。
2.2 synchronized代码块
synchronized代码块格式如下:
synchronized (object) {
}
当某个线程执行这段代码块时,该线程会获取对象object的锁,从而使得其他线程无法同时访问该代码块。而object可以是this,代表调用这个方法的对象的锁,也可以是类中的一个属性,代表获取该属性的锁,而如果没有明确的对象作为锁,也可以创建一个特殊的变量来充当锁。而针对上面例子中的test方法,可以修改为:
void test(Thread thread) {
synchronized (this) {
for (int i = 0; i < 10; i++) {
System.out.println(thread.getName() + ":" + i);
}
}
}
synchronized代码块可以实现只对需要同步的地方进行同步,而不用像synchronized方法,会对整个方法进行同步。
2.3 synchronized静态方法
先说一下,除了每个对象有一个对象锁之外,每个类还有一个类对象的内部锁,也可以称为类锁,用于对static方法的线程同步控制。那么,也就是说:
如果一个线程执行一个对象的synchronized修饰的instance方法,而另一个线程执行该对象所属类的synchronized的static方法,这时候不会发生互斥现象,因为它们的锁类型都不一样,一个是对象锁,一个是类锁,所以不存在互斥线程。
2.4 synchronized类
synchronized锁整个类的形式如下:
synchronized (MyClass.class) {
}
这种情况下,这个类对应的class对象就会被锁住。因为synchronized锁的是同一个对象的同步代码块,而如果我们想某段代码在多线程且多个对象的访问下也线程同步,我们就可以通过这种方式。当然还有一种方式,就是在synchronized的括号中定义一个固定对象。
3. synchronized字节码
3.1 反编译synchronized同步代码块
为了更深入的理解synchronized,我们使用javap来反编译一下synchronized代码块,来了解一下在字节码层面的执行过程,在开发工具IDEA中配置External Tools即可,比如对如下代码块进行反编译:
public void test(Thread thread) {
synchronized (this) {
System.out.println(thread.getName() + ":");
}
}
public void test(java.lang.Thread);
descriptor: (Ljava/lang/Thread;)V
flags: ACC_PUBLIC
Code:
stack=3, locals=4, args_size=2
0: aload_0
1: dup
2: astore_2
3: monitorenter
4: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
7: new #3 // class java/lang/StringBuilder
10: dup
... 省略
32: aload_2
33: monitorexit
34: goto 42
37: astore_3
38: aload_2
39: monitorexit
40: aload_3
synchronized是通过monitorenter
和monitorexit
这两条指令实现了锁的获取和释放过程。我们来看下JVM规范中这两条指令的描述,先看monitorenter
:
Each object is associated with a monitor. A monitor is locked if and only if it has an owner. The thread that executes monitorenter attempts to gain ownership of the monitor associated with objectref, as follows:
- If the entry count of the monitor associated with objectref is zero, the thread enters the monitor and sets its entry count to one. The thread is then the owner of the monitor.
- If the thread already owns the monitor associated with objectref, it reenters the monitor, incrementing its entry count.
- If another thread already owns the monitor associated with objectref, the thread blocks until the monitor's entry count is zero, then tries again to gain ownership.
这段话的大概意思是:每个对象有一个监视器锁(monitor),当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
- 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。
- 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.
- 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。
再来看一下monitorexit
指令:
The thread that executes monitorexit must be the owner of the monitor associated with the instance referenced by objectref.
The thread decrements the entry count of the monitor associated with objectref. If as a result the value of the entry count is zero, the thread exits the monitor and is no longer its owner. Other threads that are blocking to enter the monitor are allowed to attempt to do so.
JVM规范地址:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html#jvms-6.5.monitorexit
这段话的大概意思为:
- 执行monitorexit的线程必须是objectref所对应的monitor的所有者。
- 指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。
其实从字节码的角度我们可以看出,Synchronized代码块的底层是通过一个monitor的对象来完成的,分别通过
monitorenter
和monitorexit
指令来指向同步代码块的开始和结束位置。值得注意的是编译器将会确保无论方法通过何种方式完成,方法中的每条monitorenter
指令都将对应于一条monitorexit
指令。并且编译器会自动产生一个异常处理器来处理所有的异常,它的目的就是用来执行monitorexit
指令。所以可以看到,字节码中多了一个monitorexit
指令,它就是异常结束时被执行的释放monitor 的指令。
3.2 反编译synchronized方法
synchronized方法与synchronized代码块有些不同,方法的同步是一种隐式的同步,即无需通过字节码指令来控制的。JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。
当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor对象,然后执行方法,执行完成释放monitor对象。同样,在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。
我们同样来看一下反编译之后的代码:
public synchronized void test(Thread thread) {
System.out.println(thread.getName() + ":");
}
public synchronized void test(java.lang.Thread);
descriptor: (Ljava/lang/Thread;)V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=3, locals=2, args_size=2
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: new #3 // class java/lang/StringBuilder
6: dup
7: invokespecial #4 // Method java/lang/StringBuilder."<init>":()V
10: aload_1
11: invokevirtual #5 // Method java/lang/Thread.getName:()Ljava/lang/String;
14: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
17: ldc #7 // String :
19: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
22: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
25: invokevirtual #9 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
28: return
其中,flags的ACC_PUBLIC表示方法访问类型是public, ACC_SYNCHRONIZED表示该方法是同步方法。
- 从字节码层面可以看出,同步方法并没有同步代码块的
monitorenter
指令和monitorexit
指令,取得代之的确实是ACC_SYNCHRONIZED标识,JVM通过该访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
- 再多说下,在Java早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,而操作系统实现线程之间的切换是需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的synchronized效率低的原因。庆幸的是在Java 6之后Java官方对从JVM层面对synchronized较大优化,所以现在的synchronized锁效率也优化得很不错了,Java 6之后,为了减少获得锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁。
4. 注意事项及总结
内部锁有一些局限性:
- 无法中断一个正在试图获得锁的线程;
- 获取锁时无法设置超时时间;
- 每个锁只有一个单一的条件,这对于复杂的场景,可能不够;
最后,我们来总结下:
- Java的内置锁其实就是一种互斥锁,这意味着最多只有一个线程能持有这种锁,因此,由这个锁保护的同步代码块会以原子方式执行,多个线程在执行该代码块时也不会相互干扰。
- synchronized只能防止多个线程访问同一个对象的同步代码块,如果是多个对象的话,那其实synchronized就没什么作用。还有一点,synchronized锁的是对象,而不是代码块,这点要注意下。
- 对于synchronized方法或者synchronized代码块,当出现异常时,JVM会自动释放当前线程占用的锁,异常也只会在当前线程抛出,不会影响到其他线程,因此不会由于异常导致出现死锁现象。
- 我们在用synchronized的时候,要注意减小锁的粒度,也就是能减少同步代码块的范围就尽量减小,能在代码块加同步就不要在整个方法上加同步。
本文参考自:
海子-Java并发编程:synchronized
Java并发编程:Synchronized及其实现原理
《Java并发编程实战》