一、线程安全问题
1.产生原因
我们使用java多线程的时候,最让我们头疼的莫过于多线程引起的线程安全问题,那么线程安全问题到底是如何产生的呢?究其本质,是因为多条线程操作同一数据的过程中,破坏了数据的原子性。所谓原子性,就是不可再分性。有物理常识的小伙伴可能要反驳了,谁说原子不可再分?原子里边还有质子和中子。我们不在这里探讨物理问题,我确实也没深究过为什么被称为原子性,也许是这个原则出现的时候还没有发现质子和中子,我们只要记住在编程中所提到的原子性指的是不可再分性就好了。回到正题,为什么说破坏了数据的原子性就会产生的线程安全问题呢?我们用一个非常简单的例子来说明这个问题。
我们来看下面这段非常简单的代码:
int i = 1; int temp; while(i < 10){ temp = i; //读取i的值 i = temp + 1; //对i进行+1操作后再重新赋给i };
细心的小伙伴可能已经发现了,这不就是i++做的事情吗。没错,其实i++就是做了上面的两件事:
- 读取i当前的值
- 对读取到的值加1然后再赋给i
我们知道,在某一个时间点,系统中只会有一条线程去执行任务,下一时间点有可能又会切换为其他线程去执行任务,我们无法预测某一时刻究竟是哪条线程被执行,这是由CPU来统一调度的。因此现在假设我们有t1、t2两条线程同时去执行这段代码。假设t1执行完第5行代码停住了(需要等待CPU下次调度才能继续向下执行),此时t1读到i的值是1。然后CPU让t2执行,注意刚才t1只执行完了第5行,也就是说t1并没有对i进行加1操作然后再赋回给i,因此这是i的值还是1,t2拿到i=1后一路向下执行直到结束,当执行到第6行的时候对i进行加1并赋回给i,完成后i的值变为2。好了,此时CPU又调度t1让其继续执行,重点在这里,还记不记得t1暂停前读取到的i是几?没错是1,此时t1执行第6行代码,对i进行加1得到的结果是2然后赋回给i。好了,问题出来了,我们清楚的直到循环进行了两次,按正常逻辑来说,对i进行两次加1操作后,此时i应该等于3,但是两条线程完成两次加1操作后i的值竟然是2,当进行第三次循环的时候,读取到i的值将会是2,这样的结果是不是很诡异,这就是线程安全问题的产生。那么引发这个问题的原因是什么呢?其实就是将读和写进行了分割,当读和写分割开后,如果一条线程读完但未写时被CPU停掉,此时其他线程就有可能趁虚而入导致最后产生奇怪的数据。
那么上面这段代码怎么修改才能不产生线程安全问题呢?我们知道一条线程被CPU调度执行任务时,最少要执行一行代码,所以解决办法很简单,只要将读和写合并到一起,即合并到一行就行了:
int i = 1; while(i < 10){ i++; };
这样,我们将读和写用i++来替代,此时线程无论在哪行停止,其他线程也不会对数据产生干扰,我画一个图来形象的说明这一点(图有点丑,不要介意):
我们可以把左边的圆看成是一行代码,右边的圆被分割成了两行代码。如果数据没有破坏原子性,由于线程被调度一次的最少要执行1行代码,那么t1只要执行了这行代码,就会连读带写全部完成,其他线程再拿到的数据就是被写过的最新数据,不会有任何安全隐患;而如果数据破坏了原子性,将读写进行了分割,那么t1,读取完数据如果停掉的话,t2执行的时候拿到的就是一个老数据(没有被更新的数据),接下来t1,t2同时对相同的老数据进行更新势必会因此数据的异常。
2.注意
对于线程安全问题,需要注意以下两点:
- 只存在读数据的时候,不会产生线程安全问题。
- 在java中,只有同时操作成员(全局)变量的时候才会产生线程安全问题,局部变量不会(每个线程执行时将会把局部变量放在各自栈帧的工作内存中,线程间不共享,故不存在线程安全问题,这里不展开描述内存问题,有兴趣可自行百度)。
3.代码演示
基于上面的分析,我们通过最经典的卖票的例子来进行代码演示。需求:使用两个线程来模拟两个窗口同时出售100张票:
public class TicketThread implements Runnable{ private int ticketCount = 100; @Override public void run() { while (ticketCount > 0) { try { Thread.sleep(50); } catch (InterruptedException e) { e.printStackTrace(); } sale(); } } public void sale(){ if (ticketCount > 0) { System.out.println(Thread.currentThread().getName() + "正在出售第" + (100-ticketCount+1) + "张票"); ticketCount --; } } }
public class Main { public static void main(String[] args) { TicketThread ticketThread = new TicketThread(); Thread t1 = new Thread(ticketThread, "窗口1--"); Thread t2 = new Thread(ticketThread, "窗口2--"); t1.start(); t2.start(); } }
运行结果:
结果分析:
从结果来看出现了很多诡异的数据,很明显是发生了线程安全问题,根据上面的分析,相信你应该知道是哪里导致的了。正式由于TicketThread类中第 19,20行的代码对成员变量ticketCount的读和写进行了分割才造成的。至于线程安全问题的解决方法之一,通过synchronized关键字会在下面进行讲解。
二、使用synchronized解决线程安全问题
1.synchronized的概念
synchronized在英语中翻译成同步,同步想必大家都不陌生。例如同步调用,有A,B两个方法,必须要先调用A并且获得A的返回值才能去调用B,也就是说,想做下一步,必须要拿到上一步的返回值。同样的道理,使用了synchronized的代码,当线程t1进入的时候,另一个线程若t2想进入,就必须要得到返回值才能进入,怎么得到返回值呢?那就要等t1出来了才会有返回值。这就是多线程中常说的加锁,使用synchronized的代码我们可以想象成将他们放到了一个房间,我前边所说的返回值就相当于这个房间的钥匙,进入这个房间的线程同时会把钥匙带进去,当它出来的时候会将钥匙仍在地上(释放资源),然后其他线程过来抢钥匙(争夺CPU执行权),以此类推。
被放到房间里代码,其实就是为了让其保持原子性,因为当线程t1进入被synchronized修饰的代码当中的时候,其他线程是被锁在外边进不来的,知道线程t1执行完里边的所有代码(或抛出异常),才会释放资源。我们换个角度想,这不就是让房间(synchronized)里面的代码保持了原子性吗,某一线程只要进去了,就必须要执行完毕里边的代码别的线程再进去,期间不会有其他线程趁虚而入来干扰它,就像我上面图中左边那个圆一样,也就是相当于将本来分割的读和写的操作合并在了一起,让一个线程要么不执行,只要执行就得把读和写全部执行完(且期间不会受干扰)。
理解了我上边所说的,就再也不用纠结到底把什么代码放入synchronized中了,只要把读和写分割的代码,并且分割后会引发线程安全问题的代码放入让其保持原子性就可以了。很明显在上面TicketThread类中,就是第19和20行。
2.synchronized的三种用法
(1)同步代码块
public class SynchronizedBlockThread implements Runnable { private Object obj = new Object(); private int ticketCount = 100; @Override public void run() { while (ticketCount > 0) { try { Thread.sleep(50); } catch (InterruptedException e) { e.printStackTrace(); } sale(); } } public void sale(){ synchronized (obj) { //使用同步代码块使线程间同步 if (ticketCount > 0) { System.out.println(Thread.currentThread().getName() + "正在出售第" + (100-ticketCount+1) + "张票"); ticketCount --; } } } }
public class Main { public static void main(String[] args) { SynchronizedBlockThread blockThread = new SynchronizedBlockThread(); Thread t1 = new Thread(blockThread, "窗口1--"); Thread t2 = new Thread(blockThread, "窗口2--"); t1.start(); t2.start(); } }
代码分析:
SynchronizedBlockThread类中需要注意一点,第20行,多个线程之间的同步代码块中必须使用相同的锁(体现在代码中就是同一个对象)才能保证同步,才能使其他不进入干扰,两条线程如果使用的不是同一把锁,那么一条线程进入synchronized中且未释放资源前,另一条线程依然可以进入。同步代码块中使用的锁要求必须是引用数据类型,最常用的就是传入一个Object对象,或者使用当前类的对象,即this。
运行结果:使用synchronized是读写数据同步后没有再出现线程安全问题
(2)同步函数
public class SynchronizedMethodThread implements Runnable{ private int ticketCount = 100; @Override public void run() { while (ticketCount > 0) { try { Thread.sleep(50); } catch (InterruptedException e) { e.printStackTrace(); } sale(); } } public synchronized void sale(){ //使用同步函数使线程间同步 if (ticketCount > 0) { System.out.println(Thread.currentThread().getName() + "正在出售第" + (100-ticketCount+1) + "张票"); ticketCount --; } } }
public class SellTicketMain { public static void main(String[] args) { SynchronizedMethodThread methodThread = new SynchronizedMethodThread(); Thread t1 = Thread t2 = }
}
代码分析:
在(1)同步代码块中,我们创建了Object对象并将其当做锁来使用,那么在同步函数中,我们无法自己传入锁,那是不是同步函数中有默认的锁呢?没错,同步函数中默认使用的锁是当前类的对象,即this。下面代码证明了同步函数中使用的锁是this:
public class VerifySynchronizedThread implements Runnable { private static int trainCount = 100; private Object obj = new Object(); public boolean flag = true; @Override public void run() { if (flag) { // 执行同步代码块this锁 while (trainCount > 0) { synchronized (this) { if (trainCount > 0) { try { Thread.sleep(50); } catch (Exception e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + ",出售第" + (100 - trainCount + 1) + "票"); trainCount--; } } } } else { // 执行同步函数 while (trainCount > 0) { sale(); } } } public synchronized void sale() { // 同步函数 if (trainCount > 0) { try { Thread.sleep(50); } catch (Exception e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + ",出售第" + (100 - trainCount + 1) + "票"); trainCount--; } } }
public class Main { public static void main(String[] args) { VerifySynchronizedThread thread = new VerifySynchronizedThread(); Thread t1 = new Thread(thread, "窗口1--"); Thread t2 = new Thread(thread, "窗口2--"); t1.start(); try { Thread.sleep(40); } catch (InterruptedException e) { e.printStackTrace(); } thread.flag = false; t2.start(); } }
代码分析:
我们通过flag控制,让t1执行同步代码块,让t2执行同步函数,由于两条线程同时操作trainCount这个成员变量,因此可能会引发线程安全问题,按照我们前边的描述,使用synchronized让线程同步,但是现在t1使用的是同步代码块,t2使用的是同步函数,按照前边的分析如果他们俩使用的是通一把锁,那么当一个线程进入synchronized中的代码时,另一个线程是进不去的,从而解决线程安全问题。我们既然是在验证同步函数使用的是this锁,因此我们将同步代码块中也使用this,经过几次反复的运行,并没有发现数据错误,也就说明了同步函数使用的是this锁,为了更加准确,我们再将同步代码块中的锁换成obj试一下,发现换成obj后出现了错误数据,因此我们证明了同步函数使用的是this锁。
(3)静态同步函数
public class StaticSynchronizedThread implements Runnable { private static int ticketCount = 100; public boolean flag = true; @Override public void run() { if (flag) { while (ticketCount > 0) { synchronized (StaticSynchronizedThread.class) { // 同步代码块 if (ticketCount > 0) { try { Thread.sleep(50); } catch (Exception e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + ",出售第" + (100 - ticketCount + 1) + "票"); ticketCount--; } } } } else { // 执行静态同步函数 while (ticketCount > 0) { sale(); } } } public static synchronized void sale() { //静态同步函数 if (ticketCount > 0) { try { Thread.sleep(50); } catch (Exception e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + ",出售第" + (100 - ticketCount + 1) + "票"); ticketCount--; } } }
public class Main { 2 public static void main(String[] args) { StaticSynchronizedThread thread = new StaticSynchronizedThread(); Thread t1 = new Thread(thread, "窗口1--"); Thread t2 = new Thread(thread, "窗口2--"); t1.start(); try { Thread.sleep(40); } catch (InterruptedException e) { e.printStackTrace(); } thread.flag = false; t2.start(); } }
代码分析:
静态同步函数的形式也比较简单,仅仅是将同步函数写成静态的形式。但是需要注意的是,静态同步函数使用的锁不是this,它也不可能使用this,因为我们知道静态函数要先于对象加载,也就是说当静态同步函数被加载的时候,本类的对象即this在内存中还不存在,因此更不可能使用它。这里静态同步函数使用的锁其实是本类的字节码文件,即StaticSynchronizedThread.class。同样还使用之前的代码,将同步代码块的锁设为StaticSynchronizedThread.class来验证,运行发现不会出现错误数据,当换成其他锁时,便会出现错误数据。
3.对于synchronized的总结
- 要使用synchronized,必须要有两个以上的线程。单线程使用没有意义,还会使效率降低。
- 要使用synchronized,线程之间需要发生同步,不需要同步的没必要使用synchronized,例如只读数据。
- 使用synchronized的缺点是效率非常低,因为加锁、释放锁和释放锁后争抢CPU执行权的操作都很耗费资源。