线程安全问题synchronized和Lock接口

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/krismile__qh/article/details/89003223

一、线程安全问题

1.产生原因

  我们使用java多线程的时候,最让我们头疼的莫过于多线程引起的线程安全问题,那么线程安全问题到底是如何产生的呢?究其本质,是因为多条线程操作同一数据的过程中,破坏了数据的原子性。所谓原子性,就是不可再分性。有物理常识的小伙伴可能要反驳了,谁说原子不可再分?原子里边还有质子和中子。我们不在这里探讨物理问题,我确实也没深究过为什么被称为原子性,也许是这个原则出现的时候还没有发现质子和中子,我们只要记住在编程中所提到的原子性指的是不可再分性就好了。回到正题,为什么说破坏了数据的原子性就会产生的线程安全问题呢?我们用一个非常简单的例子来说明这个问题。

我们来看下面这段非常简单的代码:

1 int i = 1;

2 int temp;

3

4 while(i < 10){

5    temp = i; //读取i的值

6    i = temp + 1; //对i进行+1操作后再重新赋给i

7 };

  细心的小伙伴可能已经发现了,这不就是i++做的事情吗。没错,其实i++就是做了上面的两件事:

  1. 读取i当前的值
  2. 对读取到的值加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调度执行任务时,最少要执行一行代码所以解决办法很简单,只要将读和写合并到一起,即合并到一行就行了:

1 int i = 1;

2 while(i < 10){

3    i++;

4 };

这样,我们将读和写用i++来替代,此时线程无论在哪行停止,其他线程也不会对数据产生干扰,我画一个图来形象的说明这一点(图有点丑,不要介意):

我们可以把左边的圆看成是一行代码,右边的圆被分割成了两行代码。如果数据没有破坏原子性,由于线程被调度一次的最少要执行1行代码,那么t1只要执行了这行代码,就会连读带写全部完成,其他线程再拿到的数据就是被写过的最新数据,不会有任何安全隐患;而如果数据破坏了原子性,将读写进行了分割,那么t1,读取完数据如果停掉的话,t2执行的时候拿到的就是一个老数据(没有被更新的数据),接下来t1,t2同时对相同的老数据进行更新势必会因此数据的异常。

2.注意

  对于线程安全问题,需要注意以下两点:

     1,只存在读数据的时候,不会产生线程安全问题。

     2,在java中,只有同时操作静态成员变量(属于类)的时候才会产生线程安全问题,非静态全局变量和局部变量不会(每个线程执行时将会把非静态全局变量(属于对象)和局部变量放在各自栈帧的工作内存中,线程间不共享,故不存在线程安全问题)

     PS: 3,多线程执行时,在栈内存中,其实每一个执行线程都有一片自己所属的栈内存空间。进行方法的压栈和弹栈。

PS:线程类的两种写法:

1.实现Runnable接口:(推荐该写法)实现run方法;(对线程对象和线程任务进行解耦)

2.继承Thread类(本身就是Runnable的实现类):实现run方法;

(为什么非要继承不直接实例化Thread对象?因为start调用的是Thread类中的run方法,而Thread类的run()中并没有代码,所以我们需要重写run(),在其中写入需要让线程执行的代码!)

构造方法:分配新的 Thread 对象

Thread() 无参构造

Thread(String name)  参数name为线程名

Thread(Runnable target) 参数是Runnable的实现类

Thread(Runnable target, String name)  参数是Runnable的实现类,参数name为线程名

普通方法:

String

getName() 返回该线程的名称。

 int

getPriority() 返回线程的优先级。

void

setName(String name) 改变线程名称,使之与参数 name 相同。

 void

setPriority(int newPriority) 更改线程的优先级。

static void

sleep(long millis) 在指定的毫秒数内让当前正在执行的线程休眠(暂停执行),此操作受到系统计时器和调度程序精度和准确性的影响。

 void

start() 使该线程开始执行;Java 虚拟机调用该线程的 run 方法。

 String

toString() 返回该线程的字符串表示形式,包括线程名称、优先级和线程组。

static Thread

currentThread()返回对当前正在执行的线程对象的引用。

3.代码演示

   基于上面的分析,我们通过最经典的卖票的例子来进行代码演示。需求:使用两个线程来模拟三个窗口同时出售100张票:

   注:100张票是三个窗口共有的,所以总票数ticketNum一定是静态成员变量,属于这个类,共有的!如果是非静态的,就属于每一个线程类对象,那么相当于每一个窗口都有100张票,也就不会出现线程安全问题了!

1,线程类

2,测试类

3,运行结果:

4,结果分析:

从结果来看出现了很多诡异的数据,很明显是发生了线程安全问题,根据上面的分析,相信你应该知道是哪里导致的了。正式由于TicketThread类中while循环体中的代码对成员变量ticketNum的读和写进行了分割才造成的。至于线程安全问题的解决方法之一,通过synchronized关键字会在下面进行讲解。

二、使用synchronized关键字解决线程安全问题

1.synchronized的概念

  synchronized在英语中翻译成同步,同步想必大家都不陌生。例如同步调用,有A,B两个方法,必须要先调用A并且获得A的返回值才能去调用B,也就是说,想做下一步,必须要拿到上一步的返回值。同样的道理,使用了synchronized的代码,当线程t1进入的时候,另一个线程若t2想进入,就必须要得到返回值才能进入,怎么得到返回值呢?那就要等t1出来了才会有返回值。这就是多线程中常说的加锁,使用synchronized的代码我们可以想象成将他们放到了一个房间,我前边所说的返回值就相当于这个房间的钥匙(静态成员变量),进入这个房间的线程同时会把钥匙带进去,当它出来的时候会将钥匙仍在地上(释放资源),然后其他线程过来抢钥匙(争夺CPU执行权),以此类推。

  被放到房间里代码,其实就是为了让其保持原子性,因为当线程t1进入被synchronized修饰的代码当中的时候,其他线程是被锁在外边进不来的,知道线程t1执行完里边的所有代码(或抛出异常),才会释放资源。我们换个角度想,这不就是让房间(synchronized)里面的代码保持了原子性吗,某一线程只要进去了,就必须要执行完毕里边的代码别的线程再进去,期间不会有其他线程趁虚而入来干扰它,就像我上面图中左边那个圆一样,也就是相当于将本来分割的读和写的操作合并在了一起,让一个线程要么不执行,只要执行就得把读和写全部执行完(且期间不会受干扰)。

  理解了我上边所说的,就再也不用纠结到底把什么代码放入synchronized中了,只要把读和写分割的代码,并且分割后会引发线程安全问题的代码放入让其保持原子性就可以了。很明显在上面TicketThread类中,就是while循环体中的代码。

2.synchronized的三种用法

(1)同步代码块

第一种:

第二种:

运行结果:使用synchronized是读写数据同步后没有再出现线程安全问题

代码分析:

TicketThread类中需要注意一点,多个线程之间的同步代码块中必须使用相同的锁(体现在代码中就是同一个对象)(Object就是静态的)才能保证同步,才能使其他不进入干扰,两条线程如果使用的不是同一把锁,那么一条线程进入synchronized中且未释放资源前,另一条线程依然可以进入。同步代码块中使用的锁要求必须是引用数据类型(所以可以是字符串),最常用的就是传入一个Object对象,synchronized是在一个方法中的某个地方,此处的锁对象其实是this。

(2)同步函数

注:我们要在方法上加锁,该方法如果是普通方法,锁指的是当前类的对象this,如果是静态方法,需要在前面加一个static,该锁指的是该类的字节码文件.class

PS:错误写法

代码分析:同步函数使用的是this(即当前对象)锁,而Test类中对TicketThread类实例化三个对象,创建了三个针对不同对象的线程,那么锁的对象this每次都在换(t1,t2,t3)!这就违背了设置锁的初衷!三个线程使用的不是同一把锁!!!一条线程进入synchronized中且未释放资源前,另一条线程依然可以进入。显然结果就是错误的~

结果显示:

正确写法:

代码分析:在Test类中对TicketThread类仅仅实例化一个对象,针对该对象创建了三个线程,此时锁的对象this就是Ticket对象!!!所以此时的所对象是唯一的,三个线程具有相同的锁,能保证同步!

结果显示:

(3)静态同步函数

锁的是静态方法,那么该锁的对象指的是该类的字节码文件.class

代码分析:静态同步函数的形式也比较简单,仅仅是将同步函数写成静态的形式。但是需要注意的是,静态同步函数使用的锁不是this,它也不可能使用this,因为我们知道静态函数要先于对象加载,也就是说当静态同步函数被加载的时候,本类的对象即this在内存中还不存在,因此更不可能使用它。这里静态同步函数使用的锁其实是本类的字节码文件,即TicketThread.class。所以对于(2)同步函数中两种Test类的写法都是适用的,结果都是正确的!!因为此时的锁对象是唯一的!

3.对于synchronized的总结

  • 要使用synchronized,必须要有两个以上的线程。单线程使用没有意义,还会使效率降低。
  • 要使用synchronized,线程之间需要发生同步,不需要同步的没必要使用synchronized,例如只读数据。
  • 使用synchronized的缺点是效率非常低,因为加锁、释放锁和释放锁后争抢CPU执行权的操作都很耗费资源。所以尽可能使锁内的代码量少!!!

三、使用Lock接口解决线程安全问题

Lock接口的实现类ReentrantLock,一般使用它实例化Lock。

常用方法:

lock() 获取锁

unlock() 释放锁

代码示例:(售票系统)此时锁的对象是this  !!!

猜你喜欢

转载自blog.csdn.net/krismile__qh/article/details/89003223
今日推荐