携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第10天,点击查看活动详情
有JavaSE基础的同学应该对synchronized并不陌生,只需要在一个方法上或者用它包裹住一块代码块,就能自动加锁实现线程同步了,如下
使用
应用在方法上
static class _Test {
public synchronized void print() {
System.out.println(Thread.currentThread().getName() + "ing...");
}
}
@Test
public void _synchronized() throws InterruptedException {
_Test test = new _Test();
Thread t1 = new Thread(test::print, "t1");
Thread t2 = new Thread(test::print, "t2");
Thread t3 = new Thread(test::print, "t3");
Thread t4 = new Thread(test::print, "t4");
t1.start();
t2.start();
t3.start();
t4.start();
}
输出结果
t1ing...
t3ing...
t2ing...
t4ing...
若拿掉synchronized,输出结果如下
t2ing...
t1ing...
t3ing...
t4ing...
很显然,synchronized加的是一把同步锁,一次只能有一个线程获取同步资源,锁未释放时其他线程只能阻塞,当synchronized应用在对象方法上时,锁对象默认为this
应用在静态方法上
与上一例使用相同,不过此时锁对象为类本身,无论有多少线程访问,锁只有一把,不同于对象方法,有几个对象就有几把锁
同步代码块
将print()方法改为如下形式
public synchronized void print() {
synchronized (this){
System.out.println(Thread.currentThread().getName() + "ing...");
}
}
这是同步代码块写法,不同于修饰方法,这里需要在括号内写上锁的具体对象,且不能为null,这里锁了this,所以效果与修饰对象方法相同
static class _Test {
public void print(Object object) {
synchronized (object) {
if(Thread.currentThread().getName().equals("t1")){
try {
sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
System.out.println(Thread.currentThread().getName() + "ing...");
}
}
}
@Test
public void _synchronized() throws InterruptedException {
_Test test = new _Test();
Object o = new Object();
Object o2 = new Object();
Thread t1 = new Thread(()->test.print(o), "t1");
Thread t2 = new Thread(()->test.print(o2), "t2");
t1.start();
t2.start();
sleep(3000);
}
这里将形参作为锁对象,传入了两个不同的对象,如果是线程1则先睡眠1s,所以输出结果如下
t2ing...
t1ing...
原理
synchronized的执行流程大致如下
重量级锁
在操作系统中,锁不叫锁,叫管程(monitor)亦或是信号量(semaphere),而JDK是选择管程来实现锁机制的
synchronized在JDK1.6前就是默认就是重量级锁,重量级锁也就是互斥锁,也就是管程(Monitor),也就是以上讲的概念:一个线程获取锁后,CPU会阻塞其他线程尝试获取锁的线程
Monitor上会持有一个计数器,当计数器为零时则表示锁是可以获取的,否则获取就会阻塞
在Java中,synchronized选择一个对象作为锁对象,会在对象头中存储一个指向Monitor对象的指针,当线程想获取锁时,则会寻找到对象关联的Monitor对象,查询其计数器是否为0,然后执行不同的操作,这也就解释了为什么synchronized选择的锁对象不能为空
所以执行流程可以更新为
注意,在JVM虚拟机中一个完整的Java对象所占的内存里,不仅仅是存储了对象本身实例的信息,还存在着一个对象头用来存储其他的信息等等
可重入性
那么现在有一个疑问,那当我执行一个同步对象方法时,里面调用了另一个同步对象方法时,会发生什么呢?
带着这个疑问我们来看看以以下代码
static class _Test {
public synchronized void print() {
System.out.println(Thread.currentThread().getName()+"ing...");
print2();
}
public synchronized void print2() {
System.out.println(Thread.currentThread().getName()+"ing again...");
print3();
}
public synchronized void print3() {
System.out.println(Thread.currentThread().getName()+"ing again again");
}
}
@Test
public void _synchronized() throws InterruptedException {
_Test test = new _Test();
Thread t1 = new Thread(test::print, "t1");
Thread t2 = new Thread(test::print, "t2");
t1.start();
t2.start();
sleep(3000);
}
输出结果
t1ing...
t1ing again...
t1ing again again
t2ing...
t2ing again...
t2ing again again
为什么顺利执行完毕了呢,按照我们的理解,当t1进入print()的时候,自动为test对象加上了锁,此时Monitor计数器为1,执行完输出后,跳转到print2()的时候,由于并未释放锁,所以计数器依旧为1,所以也无法获取到print2()的锁,应该造成线程的死锁自身阻塞在那了
这就要引入可重入性的概念,什么是可重入性?
若一个程序或子程序可以“在任意时刻被中断然后操作系统调度执行另外一段代码,这段代码又调用了该子程序不会出错”,则称其为可重入(reentrant或re-entrant)的。即当该子程序正在运行时,执行线程可以再次进入并执行它,仍然获得符合设计时预期的结果。与多线程并发执行的线程安全不同,可重入强调对单个线程执行时重新进入同一个子程序仍然是安全的
可重入锁,也叫做递归锁,是指在一个线程中可以多次获取同一把锁,比如:一个线程在执行一个带锁的方法,该方法中又调用了另一个需要相同锁的方法,则该线程可以直接执行调用的方法【即可重入】,而无需重新获得锁
synchronized只是具有可重入性,但它并不是可重入锁(ReentrantLock),我们可以使用Lock lock = new ReentrantLock()创建可重入锁
所以当t1进入print()时,获取了test对象这把锁,所以Monitor计数器++
在print()内部调用print2()时,由于锁与print()是同一把,所以无需再次获取也无需阻塞,此时Monitor计数器再次++
print3()也是如此,此时计数器为3,然后当print3()执行完毕,计数器--,回到print()执行完毕时,计数器刚好减为0,所以释放了锁
CPU便唤醒了在同步队列中阻塞的t2线程,流程大概如下
公平锁和非公平锁
再来看一段代码
static class _Test {
public synchronized void print() {
synchronized (this) {
try {
sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(Thread.currentThread().getName() + "ing...");
}
}
}
@Test
public void _synchronized() throws InterruptedException {
_Test test = new _Test();
Thread t1 = new Thread(test::print, "t1");
Thread t2 = new Thread(test::print, "t2");
Thread t3 = new Thread(test::print, "t3");
Thread t4 = new Thread(test::print, "t4");
t1.start();
t2.start();
t3.start();
t4.start();
sleep(3000);
}
输出结果:
t1ing...
t3ing...
t4ing...
t2ing...
也许有同学迷惑了,为什么在线程内睡眠了100ms,执行结果就不是顺序的了呢?这就是公平锁和非公平锁的概念了
公平锁:多个线程按照执行顺序去访问锁,直接进入同步队列,等待锁释放依次出队
- 优点:所有入队的线程最终都能获取到锁 缺点:CPU需要依次唤醒所有阻塞的线程,这涉及到用户态和内核态的切换
非公平锁:多个线程访问锁时,会直接尝试插队获取,而不是直接入队,若锁被占用才会入队
- 优点:可以减少CPU唤醒线程的次数,减少开销 缺点:如上所示,执行顺序被打乱了,而且如果当前并发越高,则越有可能一些线程始终无法获取到锁,造成所为的饥饿问题
冷知识:sleep()方法不会释放锁,而wait(),await()方法会
而synchronized就是非公平锁
所以如上代码,当t1获取到锁后,进入睡眠,并占有锁,所以t2直接入队了
而当t3执行时t1刚好睡眠完,直接再次获取锁,t2依旧在排队
t3执行完后t4也来了,t2又被插队了,最后当t4也执行完毕了,才轮到可怜的t2
Synchronized的新生
在JDK1.6之前,synchronized一直是重量级方法,因为它本身是重量级锁,线程的阻塞和唤醒都会对CPU造成不低负载,而在JDK1.6之后,对锁进行了优化,如轻量级锁,自旋锁,适应性自旋锁等等,所以同学们使用synchronized时不要有心理负担
参考书籍+推荐阅读
《深入理解JVM虚拟机》