在多线程情况下,如果存在一个数据被多个线程同时共享,那么这个共享数据如果不做特殊处理,就容易出现紊乱。
这个特殊处理就是添加同步。
就拿售票来举例,代码如下:
public class TicketRunnable implements Runnable{
private int currentCount = 0; // 当前已售出票数
private static final int MAX = 10; // 最大10票
@Override
public void run() {
while (currentCount < MAX) {
currentCount = currentCount + 1;
String threadName = Thread.currentThread().getName();
System.out.println(threadName + "卖出了第 " + currentCount + "票");
}
}
}
public static void main(String[] args) throws InterruptedException {
TicketRunnable ticketRunnable = new TicketRunnable();
Thread thread1 = new Thread(ticketRunnable, "一号窗口");
Thread thread2 = new Thread(ticketRunnable, "二号窗口");
Thread thread3 = new Thread(ticketRunnable, "三号窗口");
Thread thread4 = new Thread(ticketRunnable, "四号窗口");
thread1.start();
thread2.start();
thread3.start();
thread4.start();
}
输出结果是:
二号窗口卖出了第 3 票
二号窗口卖出了第 5 票
四号窗口卖出了第 4 票
三号窗口卖出了第 3 票
三号窗口卖出了第 8 票
三号窗口卖出了第 9 票
三号窗口卖出了第 10 票
一号窗口卖出了第 3 票
二号窗口卖出了第 6 票
以上输出结果肯定是存在问题的,因为第1、2、7票到底是在哪个窗口卖出,根本就不知道,并且有些被多次打印,显然数据发生了紊乱。
我们可以观察,currentCount 是一个共享数据,它的取值被多个线程所影响,当多个线程同时操作同一个数据时,需要考虑线程安全问题。
为了解决数据线程安全问题,可以采用同步(synchronized)的方式来解决。
改进后的代码如下:
@Override
public void run() {
synchronized (TicketRunnable.class) {
while (currentCount < MAX) {
currentCount = currentCount + 1;
String threadName = Thread.currentThread().getName();
System.out.println(threadName + "卖出了第 " + currentCount + " 票");
}
}
}
使用 synchronized 关键字,将 currentCount 变量保护起来可以解决线程安全问题,最终打印结果如下:
一号窗口卖出了第 1 票
一号窗口卖出了第 2 票
一号窗口卖出了第 3 票
一号窗口卖出了第 4 票
一号窗口卖出了第 5 票
一号窗口卖出了第 6 票
一号窗口卖出了第 7 票
一号窗口卖出了第 8 票
一号窗口卖出了第 9 票
一号窗口卖出了第 10 票
从以上输出结果可以看出,synchronized 是有用的,售出的票数可以按照顺序依次售出。
但是,在代码设计的过程中,还要考虑合理性,从打印结果上看到一个很奇怪的现象,为什么所有的票都是在一号窗口售出的?如果多运行几次就会发现,所有的票总是由某一个具体窗口售出(具体是哪个窗口售出是CPU决定的)。
synchronized 是阻塞性的,为了防止其它线程进入而设置了一把锁,假如某一个线程抢到了优先执行权,那么这个线程会首先进入while循环,当while执行完毕之后,所有的票已经被全部售出,已经没有其它线程什么事了,所以,这段代码需要有优化,优化后的代码如下:
public class TicketRunnable implements Runnable{
private volatile int currentCount = 0; // 当前已售出票数
private static final int MAX = 10; // 最大10票
@Override
public void run() {
while (currentCount < MAX) { // ----(1)
synchronized (TicketRunnable.class) { // ----(2)
if (currentCount < MAX) { // ----(3)
currentCount = currentCount + 1;
String threadName = Thread.currentThread().getName();
System.out.println(threadName + "卖出了第 " + currentCount + " 票");
}
}
}
}
}
为了方便讲解,在代码中,标记了(1)、(2)、(3),执行到(2)时,第一个抢到CPU资源的线程会被获得同步锁,接下来会直接执行(3),其它线程执行完(1)之后,被(2)挡住(被锁挡住),只有等待持有锁的线程开锁之后其它线程才可以继续执行。所以,以上代码是非常安全的。需要注意的是,为了防止指令重排,需要将共享变量加上 volatile 关键字。
使用 synchronized 关键字需要知道,synchronized 代码块具有互斥性,只能有一个线程获取了monitor 锁,其它线程只能进入阻塞状态,等待获取 monitor 锁的线程对其进行释放。
synchronized 的同步锁可以指定一个class,也可以是一个对象,需要注意的是:
(1)与 monitor 关联的对象不能为空;
对象直接和 monitor 关联,如果对象为空,就没有 monitor 可言。
(2)synchronized 控制的范围不能过大;
synchronized 控制的范围不能过大,synchronized 会阻塞其它线程执行,
如果 synchronized 控制的范围过大会消耗大量的CPU资源,导致效率降低,
原则上,synchronized 尽量只作用于共享资源(共享数据);
(3)每一个对象只有一个 monitor,如果不同的 monitor 锁相同的方法,那么就无法做到互斥效果,比如:
synchronized (new Object()) {}
每次执行到 synchronized 代码块的时候都会重新创建新的对象,由于一个对象只有一个 monitor,
所以每次执行到 synchronized 代码块的时候次,monitor 也不相同,
所以,那些线程是相互独立的,并没有互斥这个特性。
在实际开发中,synchronized 传入的往往不是直接 new 一个对象,而是在这之前已经 new 过的对象,
这时,就必须详细分析那些不同来源的对象是否是同一个对象了。
如果N个线程的 synchronized 传入的是同一个对象,那么这N个线程持有相同的 monitor 锁,这样才是线程安全的。
(4)需要防止线程死锁的情况
【1】多个线程持有一把锁(monitor 相同),并且至少有一个线程无限循环,演示代码如下:
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
Thread thread = Thread.currentThread();
System.out.println("开始执行 " + thread.getName() + " 线程");
synchronized (object) {
while (true) {
}
}
}
});
Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
Thread thread = Thread.currentThread();
System.out.println("开始执行 " + thread.getName() + " 线程");
synchronized (object) {
}
System.out.println(thread.getName() + " 线程执行结束");
}
});
thread1.start();
thread2.start();
}
从代码上可以知道,两个线程持有一把锁,第一个线程有一个while无限循环,这样导致的结果是,
如果CPU首先执行第一个线程,第二个线程需要等待第一个线程释放锁之后才能执行,这样就形成的“死循环”。
所以,线程中的代码千万不能有死循环,否则容易死锁,“耗时操作”尽量也不要有,因为在复杂的项目架构中,
线程中的耗时操作也有可能因为某个原因导致其它线程形成死锁,如果没有死锁,也会对项目的性能造成严重的影响。
【2】不允许出现交叉锁
交叉锁容易导致死锁,什么是交叉锁呢?来,演示代码:
public static void main(String[] args) throws InterruptedException {
Object object1 = new Object();
Object object2 = new Object();
Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
Thread thread = Thread.currentThread();
System.out.println("开始执行 " + thread.getName() + " 线程");
synchronized (object1) {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (object2) {
}
}
System.out.println(thread.getName() + " 线程执行结束");
}
});
Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
Thread thread = Thread.currentThread();
System.out.println("开始执行 " + thread.getName() + " 线程");
synchronized (object2) {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (object1) {
}
}
System.out.println(thread.getName() + " 线程执行结束");
}
});
thread1.start();
thread2.start();
}
线程1的关键代码是:
synchronized (object1) {
synchronized (object2) {
}
}
线程2的关键代码是:
synchronized (object2) {
synchronized (object1) {
}
}
两个线程各持有了两把锁,两把锁的顺序是相反了,这就是`交叉锁`,在实际项目中,需要排查交叉锁的情况,因为它是一个危险的因素。
【3】还有其它情况可能会导致死锁,比如:内存不足、一问一答式的数据交换、数据库锁、文件锁等。
synchronized 还可以放在方法上,和 synchronized 代码块的作用是一致的:
public synchronized void method() {
}
最后,了解一下 this monitor 和 class monitor 的区别 ?
什么是 this monitor?
public synchronized void method1() {
}
或
public void method2() {
synchronized (this) { // this 可以换成其它对象
}
}
method1 和 method2 都不是静态方法,使用 this monitor 即可;
什么是 class monitor?
public static synchronized void method1() {
}
或
public static synchronized void method2() {
synchronized (XXX.class) {
}
}
method1 和 method2 都是静态方法,使用 class monitor 即可;
this monitor 和 class monitor 虽然在写法上有所区别,但是本质的功能是完全一致的。
[本章完]
网友评论