在实际应用过程中使用多线程,可以给我们的程序带来性能上非常大的提升,但是同时,如果我们的线程使用不合理,也会带来非常多不可控的问题,最常见的问题就是线程安全问题了。
也就是说当多个线程同时访问某个方法的时候,这个方法无法按照我们的预期行为来执行,那么这个方法就是认为是线程不安全的。
其实导致我们线程不安全的原因主要有三个:原子性,有序性,可见性。当谈到Synchronized同步锁的时候无疑就是与原子性相关的了
多线程环境的原子性问题
什么是原子性呢?
在数据库事务的ACID特性中就有原子性,它是指当前操作中包含的多个数据库事务操作,要么全部成功,要么全部失败,不允许存在部分成功,部分失败的情况。而在多线程中原子性与数据库事务的原子性相同,它是指一个或多个指令操作在CPU执行过程中不允许被中断。
我们可以通过一段代码来进行演示:
public class AtomicExample {
volatile int i = 0;
public void incr(){
i++;
}
public static void main(String[] args) throws InterruptedException {
AtomicExample atomicExample = new AtomicExample();
Thread[] threads = new Thread[2];
for (int j = 0; j < 2; j++) {
threads[j] = new Thread(()->{
for (int k = 0; k < 10000; k++) {
atomicExample.incr();
}
});
threads[j].start();
}
threads[0].join();
threads[1].join();
System.out.println(atomicExample.i);
}
}
在上述代码中启动了两个线程,每个线程对变量i累加10000次,然后打印出累加后的结果。我们从结果中发现,原本我们期望值是20000,但是打印出来的i值都是一个小于20000的数,和预期的结果不一致,导致这个现象产生的原因就是原子性问题。
其实从本质上来说,原子性问题产生的原因有两个。CPU时间片的切换以及执行指令的原子性(也就是说线程运行的程序或者指令是否具备原子性)
我们来看一下CPU时间片切换,当CPU不管因为何种原因处于空闲状态时,CPU会把自己的时间片分配给其他线程来处理,如图:CPU通过上下文切换来提升资源利用率。
i++指令的原子性
在Java程序中,i++操作看起来更像是一个不可分割的指令,但实际上并非如此。我们通过Javap -v命令可以查看AtomicExample类中incr()方法的字节码文件。
我们可以发现,i++操作实际上是三个指令:getfield,iadd,putfield。
- getfield,把变量i从内存加载到CPU的寄存器中。
- iadd,在寄存器中执行+1操作。
- putfiled,把结果保存到内存。
不过这三个指令实际上并不具备原子性,也就是说,CPU在执行的过程中会存在中断的情况,这种情况就会导致原子性问题。
假设有两个线程对i变量进行修改,那么可能执行过程就会如下:
- 线程1先获得CPU的执行权,在CPU将i=0加载到寄存器中后出现的线程切换,CPU把执行权交给切换给线程2并保留当前的CPU上下文。
- 线程2同样去内存将i加载到寄存器中进行计算,然后把结果放回到内存。
- 线程2释放了CPU资源,线程1重新获得执行权后恢复了CPU上下文,但是这是的i值还是0.因为此时的i是沿用线程开始时候加载的i值,而不是线程2更改之后的i值。
- 最后导致结果比预期的小。
如何解决原子性问题?
通过上述问题的分析,其实我们发现,多线程环境下线程的并行或者切换均会导致最终执行结果不符合预期,解决问题的办法可以从两个方面考虑。
- 不允许当前非原子指令在执行过程中被中断,也就是说保证i++操作在执行过程中不存在上下文切换。
- 多线程并行执行导致的原子性问题可以通过一个互斥条件来实现串行执行。
在Java中,synchronized关键字提供了这样一个功能,在我们incr()方法上增加synchronized关键字后,可以保证下面这段i变量不会被其他线程所影响。
public synchronized void incr(){
i++;
}
Java中synchronized同步锁
介绍
导致线程安全问题的根本原因在于,存在多个线程同时操作一个共享资源,要解决这个问题,就需要保证对共享资源访问的独占性,因此人们在Java中提供了一个synchronized关键字,我们称之为同步锁,他可以保证在同一时刻,只允许一个线程执行某个方法或代码块。
synchronized同步锁具有互斥性。这相当于线程由并行执行变成了串行执行,正因为如此,使系统损失了性能。下面举例synchronized的使用方法。
使用方法
作用在方法级别的,表示针对m1()方法加锁,当多个线程同时访问m1()方法时,同一时刻只有一个线程能执行。
public synchronized void m1(){
}
作用在代码级别,表示针对某一段线程不安全的代码加锁,只有访问到synchronized(this)这行代码时,才会去竞争锁资源。
public void m2(){
sychronized(this){
}
}
下图是增加了synchronized同步锁之后的执行流程
从中我们可以看出多个线程同时访问加synchronized关键字修饰的方法时,需要先抢占一个锁标记,只有抢到锁标记的线程才有资格调用incr()方法。这就使得在同一时刻只有一个线程执行i++操作,从而解决了原子性问题。
作用范围
我们对一个方法增加sychronized关键字之后,当多个线程访问该方法时,整个执行过程会变为串行执行,这种执行执行方式上文也说了非常影响程序的性能,那么我们有什么方法可以保证安全性与性能间的平衡呢?
实际上,synchronized关键字只需要保护可能存在线程安全的代码,因此,我们可以通过控制同步锁的作用范围来实现这个平衡机制。在sychronized中,提供了两种锁,一种是类锁,一种是对象锁。
类锁
类锁实际上是全局锁,当多个线程调用不同对象实例的同步方法时会产生互斥,具体实现方式如下。
- 修饰静态方法:
public static sychronized void m1{
}
- 修饰代码块,sychronized中的锁对象是类,也就是lock.class。
public class Lock{
public void m2(){
sychronized(Lock.class){
}
}
}
下面展示使用类锁来实现对象实例,从而实现互斥。
public class SynchronizedExample {
public void m1(){
synchronized (SynchronizedExample.class) {
while (true) {
System.out.println("当前访问的线程:" + Thread.currentThread().getName());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public static void main(String[] args) {
SynchronizedExample set1 = new SynchronizedExample();
SynchronizedExample set2 = new SynchronizedExample();
new Thread(()->set1.m1(),"t1").start();
new Thread(()->set2.m1(),"t2").start();
}
}
- 该程序中定义了一个m1()方法,该方法中实现了一个循环打印当前线程名称的逻辑,并且这段逻辑使用类锁来进行保护的。
- 在main()方法中定义了两个SynchronizedExample对象实例set1和set2,又分别定义了两个线程来调用这两个实例的m1()方法。
根据类锁的作用范围可以知道,即便是多个对象实例,也能够达到互斥的目的,因此最终输出的结果是,哪个线程先抢到了锁,哪个线程就持续打印自己的线程名称。
对象锁
对象锁是实例锁,当多个线程调用同一个对象实例的同步方法时会产生互斥,具体实现方法如下;
- 修饰普通方法:
public synchroized void m1(){
}
- 修饰代码块,sychronized中的锁对象是普通对象实例。
public class Lock{
Object lock = new Object();
public void m2(){
synchronized(lock){
}
}
}
下面这段程序演示了对象锁的使用方法:
public class SynchronizedForObjectExample {
Object lock = new Object();
public void m1(){
synchronized (lock){
while (true){
System.out.println("当前获得锁的线程:"+Thread.currentThread().getName());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public static void main(String[] args) {
SynchronizedForObjectExample set1 = new SynchronizedForObjectExample();
SynchronizedForObjectExample set2 = new SynchronizedForObjectExample();
new Thread(()->set1.m1(),"t1").start();
new Thread(()->set2.m1(),"t2").start();
}
}
运行结果:
当前获得锁的线程:t1
当前获得锁的线程:t2
当前获得锁的线程:t1
当前获得锁的线程:t2
当前获得锁的线程:t1
当前获得锁的线程:t2
当前获得锁的线程:t1
当前获得锁的线程:t2
当前获得锁的线程:t1
当前获得锁的线程:t2
当前获得锁的线程:t2
当前获得锁的线程:t1
其实我们从上面两个代码就可以发现,对于两个几乎相同的代码,在使用了对象锁的情况下,当两个线程分别访问不同对象实例的m1()方法时,并没有达到两者互斥的目的,看起来锁似乎没有生效,实际上并不是锁没有生效,问题的根源在于sychronized(lock)中锁对象lock的作用范围太小了。
Class是在JVM启动过程中加载的,每个.class文件被装载后会产生一个Class对象,Class对象在JVM进程中是全局唯一的。通过static修饰的成员对象及方法的生命周期都属于类级别,它们会随着类的定义被分配和装载到内存,随着类的卸载而被回收。
因此,类锁和对象锁最大的区别就是锁对象lock的生命周期不同,如果要达到多个线程互斥,那么多个线程必须竞争同一个对象锁。
在上述代码中Object lock = new Object();构件的锁对象的生命周期是由SychronizedForObjectExample对象实例来决定的,不同的SychronizedForObjectExample的实例会有不同的lock锁对象,由于没有形成竞争,所以不会互斥。如果想要让上述程序达到同步,那么我们可以对lock锁对象增加static关键字。
static Object lock = new Object();
关于Synchronized同步锁的思考
经过前面的分析,我们大概对同步锁有了一些基本的认识,同步锁的本质就是实现多线程的互斥,保证同一时刻只有一个线程能够访问加了同步锁的代码,使得线程安全性得到保证。我们可以思考一下,为了达到这个目的,我们应该怎么做呢?
- 同步锁的核心特性是排他,固同步锁我们也称为排他锁,要达到这个目的,多个线程必须去抢占同一个资源。
- 在同一时刻只能有一个线程执行加了同步锁的代码,意味着同一时刻只允许一个线程抢占到这个共享资源(锁),其他没抢到的线程只能等待。
- 如果非常多的线程被阻塞,那么我们需要一个容器来存储线程,当获取锁的线程执行完成任务后并释放了锁,要从这个容器中唤醒一个线程,此时被唤醒的线程会再次尝试抢占锁。
synchronized同步锁标记存储分析
如果synchronized同步锁想要实现多线程访问中的互斥性,就必须保证多个线程竞争同一个资源,这个资源有点类似于生活中停车位上的红绿指示灯,绿灯表示车位闲置可以停车,红灯反之。在sychronized中,这个共享资源就是synchronized(lock)中的lock锁对象。
这就是对象锁和类锁能够影响锁的作用范围的原因,如果多个线程访问多个锁资源,就不存在竞争关系,也达不到互斥的效果。
所以,从这个层面中,实现锁互斥要满足如下两个条件:
- 必须竞争同一个共享资源
- 需要有一个标记来识别他们当前锁是空闲还是繁忙。
第一个条件通过lock锁对象来实现即可,第二个条件需要有一个地方来存储抢占锁的标记,否则当其他线程来抢占资源时,不知道当前是应该正常执行还是排队,实际上,这个·锁是标记在对象头中的。我们下面来简单分析一下对象头。
Mark Word的存储结构
参考书籍:
《Java并发编程深度解析与实战》
《Java编程思想》
《Java并发编程实战》