前言
当我们讨论多线程的时候,有一个事情是怎么也绕不开的——线程安全性,或者说,数据一致性。
而在java程序中,synchronized
关键字,就是解决线程安全问题的最方便也是最基础的手段。
这篇文章,我们就从线程竞争开始讨论synchronized出现的原因,然后讨论该关键字底层的实现逻辑,以及与底层实现相关的锁升级的概念。最后还会说一些面试题中跟synchronized相关的几个问题。
synchronized的出现原因和用法
线程不安全的隐患
在多线程的环境下,如果没有特殊处理,多个线程对同一份数据进行读写操作几乎是必定会发生的情况。
比如下面这段代码
public class ThreadDemo1 implements Runnable {
private int totalCount = 100;
public static void main(String[] args) throws IOException {
Runnable thread1 = new ThreadDemo1();
for(int i = 0; i < 5; i++) {
new Thread(thread1).start();
}
}
@Override
public void run() {
for(int i = 0; i < 20; i++) {
totalCount--;
try {
Thread.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " -- " + totalCount);
}
}
}
上面的代码有一个初始值为100
的变量totalCount
,并且起了5
个线程,每个线程通过for循环从这个变量中减去20
,在每次减操作之后,把totalCount
输出出来。
问题在于,输出语句输出的totalCount与计算得到的totalCount的值可能是不一样的。
比如,在thread1
得出结果99并准备输出的时候,thread2
把结果该变量改成了98,这个时候,thread1
就输出了98。
为了让结果更明显,我们在计算语句和输出语句之间让线程睡眠5毫秒,这样最后的输出结果如下图:
如果我们仔细分析一下这种情况,就知道这个问题的根源在于多个线程同时访问了一个数据,导致任何一个线程,都不能百分百掌控某个变量。为了解决这个问题,synchronized
就应运而生了。
如何使用synchronized
synchronized
关键字,通过给某个对象加锁的方式,保证了同一时间,只能由一个线程访问某一段代码。
下面这段demo代码显示了synchronized
关键字的使用方法
Object obj = new Object();
synchronized(obj) {
// code block...
}
不难发现,要使用synchronized关键字,后面的括号是要传入一个对象的。这个对象,就是一个用来被加锁的对象。
这个被加锁的对象,有没有什么要求呢?
答案是:该对象不能是String类,也不能是基础类型的数据。其他的对象都可以放在synchronized关键字后面的括号里。
我们也见过synchronized的另外一种用法,是直接把这个关键字加在方法声明行,就像下面代码所示的一样,这种做法,就相当于给该方法所在的对象加了一把锁。
public synchronized void method() {
// code block
}
// 相当于下面的代码
public void method() {
synchronized(this) {
// code block
}
}
那就会让人很好奇啊,仅仅一个关键字synchronized和一个被加锁的对象,就能保证线程的安全性了吗?
下面通过synchronized的底层实现原理,来研究一下是怎么保证线程安全性的。
synchronized的实现原理
synchronized,就是保证同一时间,只有一个线程能够访问代码块中的内容。
是怎么保证的呢?
在java中,任意一个对象同一时刻只能被加一把锁,成功给这个对象加上锁的线程,得到执行同步代码块的权利。
那么是怎么给一个对象加锁的呢?
其实就是在这个对象字节码的头两位做标记,具体的实现超出了本文的范围,这里暂时不做讨论。
这个给对象加锁的过程,就涉及到了一个锁升级的概念。
锁升级
在JDK的早期版本(1.5之前),当线程尝试给某个对象加锁的时候,会直接向操作系统申请锁,我们都知道任何程序涉及到操作系统的操作时,运行效率会不可避免的下降。
为了提高加锁的效率,JDK用锁升级的概念来代替无脑申请系统锁。锁的级别从低到高分别是:偏向锁、自旋锁、系统锁。
偏向锁
偏向锁是最轻量的锁,本质是压根儿就没上锁。
当只有一个线程尝试给某个对象上锁的时候,直接在这个Object上记录下这个线程的线程ID。
当又有一个线程来尝试给这个对象加锁的时候,先对比这个线程的线程ID和记录下来的线程ID,如果相同,会让这个线程继续运行。
而如果不同,会将锁升级成自旋锁。
自旋锁
什么是自旋锁呢?
简单的理解就是不断去检查这个对象上的锁有没有释放,相当于有个while(true)的循环不断去检查。
默认的自旋次数(检查次数)是10次,如果10次之内,该对象的锁被释放了,当前这个自旋的线程会拿到这把锁。
如果自旋次数达到上限,该锁就升级成系统锁。
所以我们知道,自旋锁是一直存在于用户态的锁,不涉及跟操作系统的交互。一直在调用CPU。
系统锁
如果升级到系统锁,那么该线程不在耗费CPU去检查锁有没有被释放。而是进入操作系统的等待队列,由操作系统去调度线程,决定什么时候获得该对象的锁。
可以想象,这种锁是特别重量级的锁,运行效率不会太高。
几道跟Synchronized相关的面试题
面试题1:同步方法和非同步方法能否被同时调用?
这是肯定的可以的啊。既然说起了是同时调用,那么肯定是在两个线程里。调用非同步方法(即普通方法),是不需要抢锁的,因此是可以同时执行的。
那么就是说,非同步方法,是可以读到同步方法的中间状态的。
来看下面这个代码:
public class ThreadDemo1 {
private String accountName;
private int balance;
public synchronized void setAccountAndBalance() {
this.accountName = "testAccount";
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
this.balance = 100;
}
public void readAccoutAndBalance() {
System.out.println("account: " + this.accountName + "--- balance " + this.balance);
}
public static void main(String[] args) {
ThreadDemo1 demo = new ThreadDemo1();
// thread1 is going to set account and balance
(new Thread(new Runnable() {
@Override
public void run() {
demo.setAccountAndBalance();
}
})).start();
// thread2 is going to get account and its balance
(new Thread(new Runnable() {
@Override
public void run() {
demo.readAccoutAndBalance();
}
})).start();
}
}
这段代码模拟了银行开户的流程,首先有个方法setAccountAndBalance
来初始化一个账户和其中的余额。并且用sleep方法在设置账户和设置余额中间模拟了费时的操作。
同时有个方法readAccoutAndBalance
去读取这个账户和余额,这样就会读到中间的状态。
面试题2: synchronized是不是可重入锁?
首先要明确,什么是可重入锁?
顾名思义,就是可以被重新进入的锁。
在实际应用中,相当于在一个同步方法中调用另一个同步方法。如果一个线程已经得到了这个对象的锁,那么同一个线程仍然能得到另一个同步方法的锁。
如果同一个对象的锁不让同一个线程重复进入,上面这种一个同步方法调用另一个同步方法的情况就是一种死结。