前言
多线程虽然在某些场景下提升了程序的性能,但当出现多个线程抢占(修改)同一个资源时,线程不安全性的问题就容易出现,造成重大损失。解决这种问题的方法之一就是同步,本篇文章中,将对线程的同步进行讲解,主要针对
synchronized
关键字的使用进行演示,同时将对类锁和对象锁二者的概念和使用进行分析,希望对各位读者有所帮助。
一、多线程为什么需要同步
我们在之前的文章中已经了解到,多线程可以更加充分地利用硬件设备,提高程序的运行效率,多线程的优势这么大,为什么需要加入同步这一个概念呢?我们来看下面这个经典的抢票案例。
火车站一共有10张票,小红,小明,小蓝三个人都在不停的抢票,直到抢到最后一张票结束。下面是具体的代码演示:
// 火车站类
class Train {
private int ticketNum = 10;
private boolean flag = true;
public void buy() {
while (flag) {
if (ticketNum <= 0) {
flag = false;
return;
}
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "抢到了票,剩余票数为"+ --ticketNum);
}
}
}
// 测试类代码
public class TicketTest {
public static void main(String[] args) {
Train train = new Train();
Thread thread1 = new Thread(()->{
train.buy();
},"小明");
Thread thread2 = new Thread(()->{
train.buy();
},"小蓝");
thread1.start();
thread2.start();
}
}
为了模拟并发效果,我们让票数真正扣减之前,让线程睡眠100毫秒。运行程序后,结果参考如下:
我们可以看到,出现了剩余票数为
-1
的情况,最终运行的结果超过了我们允许的阀值,这就是高并发下可能存在的问题。(其实仔细观察后还能发现,程序执行过程中还出现两个线程抢到票后剩余票数一致的情况,这也是高并发下的漏洞)。
我们仔细想想,为什么会出现上面这种问题呢?
其实本质上是因为高并发下,多个线程轮流抢占CPU资源,都通过了方法对资源的限制判断后,而后再对资源进行了修改,此时就会出现资源消耗超过阀值的问题。
知道了问题的存在,那么可以怎么样去解决这个问题呢?
答案很简单,就是排队。也就是说,但多个线程要调用某个方法时,最先调用方法的线程就锁定了这个方法,其他线程调用这个方法时发现已经有其他线程调用这个方法,就会进入阻塞状态,等待最先调用的线程执行完方法后再抢占调用该方法的权利。
举个类似的例子,像是公园里面有100个人但只有1个厕所,当第一个上厕所的人把门锁上后,其他人就都上不了厕所了,只有当第一个人上完厕所后,其他人才能上。而映射到代码上,厕所的门锁就是代码中的监控锁,上厕所的人需要判断当前厕所是否已经有人,这种排队等待的概念就是同步。
二、synchronized关键字的使用和锁的引入
我们在第一节中讲到了同步,与其相对应的关键字就是synchronized
。对于一个对象的方法, 如果没有synchronized关键字修饰, 该方法可以被任意数量的线程,在任意时刻调用。对于添加了synchronized
关键字的方法,任意时刻只能被唯一的一个获得了对象实例锁的线程调用。
这里我们提到了对象实例锁,这是什么东西呢?可以理解为Java中的每一个对象都有一把唯一的内置锁,我们利用这个特性,常常把对象实例锁配合synchronized
一起使用。也可以这么理解,synchronized
指明了某段方法需要同步,但总得需要一个标识来告诉其他线程,当前这个方法内已有其他线程调用了,这就是锁存在的意义。
下面我们就来使用synchronized
关键字来解决我们上一节遇到的高并发问题。
class Train {
private int ticketNum = 10;
private boolean flag = true;
public synchronized void buy() {
while (flag) {
if (ticketNum <= 0) {
flag = false;
return;
}
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "抢到了票,剩余票数为"+ --ticketNum);
}
}
}
实际上,我们改动的地方只有一处,就是给buy()
方法加上了synchronized
关键字。可能有些读者会疑问,不是说synchronized
关键字需要配合锁来使用吗,怎么这里没看到?其实是因为当synchronized
关键字修饰普通方法时,默认把this
作为锁的对象。我们来看一下结果:
三、对象锁和类锁的区别和使用
我们在第二节中使用了synchronized
关键字来实现了同步。但实际上,synchronized
关键字修饰的方式有很多种,简单划分的话可以分为下面2种:
(1)用于方法上:静态方法和普通方法
public class LockA {
public synchronized static void methodA(){
...
}
public synchronized void methodB(){
...
}
}
这两者看上去很相似,但实际上用法却大不一样。前者静态方法是多个对象间共享的,而后者普通方法则是每个对象独占的。
(2)用于代码块上
class LockB {
public void methodA() {
synchronized (this) {
...
}
}
public synchronized void methodB() {
synchronized (LockB.class){
...
}
}
}
我们可以看到,上面的例子中,区别只在于代码块中锁的对象不同。实际上this
表示当前LockB的实例对象,而LockB
表示的是LockB这个类。
类锁
实际上,根据锁的对象不同,还可以分类为类锁和对象锁。多个类对象共享一个class对象,共享同一组的静态方法,使持有者可以同步地调用静态方法。当synchronized指定修饰静态方法或者class对象的时候,拿到的就是类锁。
class LockC {
public synchronized static void methodA() {
...
}
public synchronized void methodB() {
synchronized (LockB.class){
...
}
}
}
上面这个类中,虽然synchronized
修饰的地方不同,但实际上锁的对象都是同一个。所以如果二者同时调用,是可以同步成功的。
对象锁
对象锁,是用来对象的,虚拟机为每个的非静态方法和非静态域都分配了自己的空间,不像静态方法和静态域,是所有对象共用一组。所以synchronized修饰非静态方法或者this的时候拿到的就是对象锁。
class LockD {
public synchronized void methodA() {
...
}
public synchronized void methodB() {
synchronized (this){
...
}
}
}
上面的代码中,其实使用的锁都是当前对象。二者实现的效果差不多,只是说代码块要更加灵活一些。
需要注意的是,类锁和对象锁的锁对象不同,所以使用起来要小心混淆,防止出现锁不生效的情况。
public class TicketTest {
public static void main(String[] args) {
Train train = new Train();
Thread thread1 = new Thread(()->{
train.method1();
},"小明");
Thread thread2 = new Thread(()->{
train.method2();
},"小蓝");
thread1.start();
thread2.start();
}
}
class Train {
private int ticketNum = 10;
private boolean flag = true;
public synchronized void method1() {
buy();
}
public void method2(){
synchronized (this){
buy();
}
}
private void buy() {
while (flag) {
if (ticketNum <= 0) {
flag = false;
return;
}
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "抢到了票,剩余票数为"+ --ticketNum);
}
}
}
比如上面的代码中,由于两个方法的锁对象都是自身,所以不会出现小蓝或者小明双方都抢到票的情况。但如果是下面这种情况,一个对象锁一个类锁,那么同步就会不起作用了。
class Train {
private int ticketNum = 10;
private boolean flag = true;
public synchronized void method1() {
buy();
}
public void method2(){
synchronized (Train.class){
buy();
}
}
private void buy() {
...
}
}
因此在实际使用中,我们要清楚当前同步的锁对象具体是谁,避免出现有加synchronized
关键字但还是同步失败的乌龙。
四、注意事项
实际上,我们可以把线程的同步机制理解为是使某一段代码串行化运行,但同步机制虽好,但却不应过度使用。原因也很简单,使用多线程的目的就是为了加快线程的运行效率,如果过度使用同步机制,那么也就丧失了我们利用多线程优势的初衷。
因此,在开发中我们需要注意,对于需要同步的方法块,我们尽量要做到细颗粒化。比如一个方法中有一部分是无直接关系的查询功能,一部分是修改功能,那么针对整个方法进行同步就不太合适,我们要尽可能地只对修改功能部分的代码进行同步即可。
参考文章:
synchronized的修饰方法和修饰代码块区别
https://blog.csdn.net/TesuZer/article/details/80874195