黑马程序员——多线程3:线程安全
2014-11-22 21:59
141 查看
------- android培训、java培训、期待与您交流! ----------
1. 问题的产生
我们通过前面提到的售票的例子进行说明。
代码1:
正常情况下,当票数为0时应停止程序的运行,但是由于1、2、3号线程均具备执行资格,所以在获得执行权以后将继续执行各自的输出语句,打印余票数量——0、-1、-2。在这种情况下就出现了问题。
2. 问题现象模拟
为了能让大家能够真实观察到上述问题的发生,我们对上述代码1进行一定地修改——在if判断语句之后,输出语句之前调用Thread类的静态方法——sleep,令线程进入冻结状态,以此来模拟CPU随机分配执行权的现象。
注意:由于sleep方法对外声明了InterruptedException异常,所以要对该异常进行处理。但是Tickets类的run方法是通过复写Runnable接口的run方法而来,而Runnable接口的run并未声明异常,因此其子类的run方法也不能声明异常。因此,只能在方法内就地try-catch处理。关于InterruptedException详细说明,请参考后面的博客。
代码2:
Thread-1_____-1
Thread-0_____0
Thread-0_____-2
出现了负数和零票数。而这种现象是绝对不能在现实生活中发生的。
那么我们上面描述的问题就是:线程的安全问题。这类问题最为棘手的地方在于:项目开发完成以后在测试阶段可能并没有检测出问题,但是一旦用户实际使用时就有可能因各种状况的发生而产生类似的线程安全问题。
3. 思考与解决
1) 问题产生原因
那么产生上述线程安全的问题的原因是:在多个线程在共享一个数据资源(例如上述车票对象中的车票数量成员变量)的前提下,当其中一个线程还没有执行完毕操作数据资源的多条语句之前,就因CPU分配执行权的随机性导致另一个线程执行数据资源操作语句,最终造成共享数据错误。
解决这个问题的方法很简单:只要保证每个线程在执行完操作共享数据的语句之后,再另其他线程对数据资源进行操作即可避免错误的发生。换句话说在某个线程执行完毕以前,其他线程不可以执行操作共享数据额语句。
2) 解决方法
Java语言中为解决线程安全问题,提供了专业的解决方法——同步代码块。其书写格式如下:
代码3:
(a) synchronized关键字后的小括号内需要传递一个对象,目前先暂时理解为任意对象即可。
(b) run方法中定义的是需要由子线程执行的全部代码,但是能否将这些代码全部放入同步代码块呢?答案是否定的,因为同步代码块的执行需要额外耗费一定的系统资源,因此尽量减少同步代码块内的代码对于提高程序的执行效率是非常有必要的。这也是同步代码块在提高线程安全的同时,所付出的代价。那么如何决定哪些代码需要被同步,而哪些代码不需要呢?技巧就是:同步代码块内应只包含,操作共享数据的语句。以代码2为例,if判断语句以及后接的输出语句使用了共享数据count,而之前的while循环并没有。
在代码2内加入了同步代码块,并通过this关键字传递当前对象,
代码4:
4. 同步代码块
1) 原理
我们就以上述代码4为例,说明同步代码块的原理。同步代码块就好比是一道门,一道通往共享数据的门,过了这道门就可以操作共享数据。而synchronized关键字后需要传入的对象就好比是门上的锁。而这个锁有两个状态(或称为标记位)——锁上和没锁上。
当没有任何线程执行过同步代码块中的代码之前,锁的默认状态是没有锁上的,那么第一个执行同步代码块的线程,比如0号线程,就可以进入到“门”里面,但它并不急于执行里面的代码,而是先将“锁”锁上,保证其他线程无法“进入”,然后才开始执行代码。
当0号线程执行到sleep语句并进入冻结状态后,CPU将执行权分配给了其他线程,比如1号线程。此时1号线程开始执行run方法,但是由于此时同步代码块上是“锁着的”,因此只能止步于同步代码块,其他线程即使分配到执行权也是如此。只有等到,0号线程唤醒以后,再由CPU分配到执行权,并执行完毕同步代码块中的语句以后,“锁”才能被再次打开,这样其他线程才能陆续执行同步代码块中的代码,而且也要重复“上锁”与“开锁”动作。
这样通过上述“锁”的机制,就保证了,即使在人为剥夺线程执行权,甚至执行资格的情况下,也能使得同步代码块在同一时间只由一个线程执行,避免线程安全问题的产生。
那么通过上述同步技术实现操作共享数据代码线程安全的思想,也可以称作实现代码的原子性。因为我们可以认为原子是不可分割的最小物质单元(事实并非如此,因为原子还可以继续分为原子核和核外电子,这里仅仅是一个类比),因此称代码具有原子性表示,这一段代码的执行将不会被打断。当实现了某段代码的原子性以后,当有多个线程同时来执行这段代码时,多个线程之间就形成了互斥的效果——同一时间只能有一个线程执行。
2) 前提
虽然同步代码块很好的避免了线程安全问题,但却不利于程序运行效率的提高,因此,应在符合一定前提条件的情况下使用:
前提一:必须要有两个及以上的线程运行,包括主线程。如果是单线程,那也就不存在线程安全问题了。
前提二:必须是多个线程使用同一个“锁”。如果说两个线程同步代码块中传入的是两个不同的对象,那么即使当第一个线程执行同步代码块时“上了锁”,却并没有上第二个线程同步代码块的锁,因为这是两个不同锁。所以使用两个不同的“锁”是不能保证两段代码的线程安全的。
我们可以以上厕所为例进行类比说明。比如,一个厕所有两个坑,代表两个不同的操作共享数据的代码。坑本身没有锁,那么为了安全的上厕所呢,每进来一个人就要把厕所的门锁上,这样一来无论谁进来使用哪个坑都能保证安全。再比如,一个厕所还是有两个坑,但是厕所本身没有锁,因此为了上厕所的安全,两个坑各自有一把锁。假如甲进厕所使用A坑,将A坑的锁锁上了,那么这能保证B坑的锁也锁上吗?当然不能。因此此时乙就可以进到B坑,此时就出现了两个线程同时对同一个共享数据进行操作的现象。
这两个前提也适用于其他同步实现机制,包括我们后面将要讲到的同步函数和静态同步函数。
3) 优缺点
至此我们就可以总结出同步代码块的优缺点:
优点:解决了线程安全问题。
弊端:由于每次都要判断“锁”,消耗了系统资源,使程序运行效率下降。
5. 同步函数
1) 示例
我们再通过模拟一个现实生活中的事例,来复习同步代码块,并引入新的内容——同步函数。
需求:模拟两个储户同时在一个银行(限定在一个金库)存款的动作。每个储户共存3次,每次100元。
分析:这个事例中的共享资源是银行金库,或者称为存款总额。两个储户就是两个线程,同时向同“一个”金库存钱,那么为了保证唯一性在整个代码中只能创建一个银行对象,并使这个银行对象与两个储户对象产生联系,以便储户可以操作银行的存款总额。
实现方式:定义银行类——Bank,其内部定义表示存款总额的私有整型变量sum和增加存款的方法add。定义实现了Runnable接口的储户类Custom,构造方法中初始化一个银行对象。run方法中使用for循环分3次进行存款。在主函数中首先创建唯一的一个银行对象,再创建两个Thread对象,构造方法中传入两个储户对象和储户名称,两个储户对象均传入唯一的银行对象。最后开启线程。
代码:
代码5:
1号顾客-----sum = 100
2号顾客-----sum = 200
1号顾客-----sum = 300
2号顾客-----sum = 400
1号顾客-----sum = 500
2号顾客-----sum = 600
从结果上看确实实现了两个储户同时存款的动作,存款总金额为600元。
2) 问题思考
我们初步完成了上述事例的需求,接下来就需要思考这段代码是否存在线程安全为问题。我们可以从以下三个方面思考:
(a) 明确哪些代码是多线程执行代码
显然,Custom类中run方法内的代码均是多线程执行代码,这当然也包括Bank类的add方法代码。
(b) 明确共享数据
最直观来说Bank对象是被两个线程所“共享”的,而对象中的sum成员变量就是被“共享”操作的数据资源。
(c) 明确多线程执行代码中哪些语句是操作共享数据的。
代码5中Bank类的add方法直接操作了sum成员变量,并且涉及语句有两句,这就最有可能造成线程安全问题:当某个线程刚执行完第一句,CPU就将执行权分配给了其他线程,最终导致问题的出现。
那么同样为了方便大家观察问题现象,我们再次通过sleep方法模拟上述(c)中可能会发生的问题。对代码5中的add方法进行修改:
代码6:
2号顾客-----sum = 200
1号顾客-----sum = 200
2号顾客-----sum = 400
1号顾客-----sum = 500
2号顾客-----sum = 600
1号顾客-----sum = 600
这样就出现了“重复存款”的错误现象,说明代码5是存在线程安全问题的。
小知识点1:
我们在代码6中选择对sleep调用语句进行就地try-catch处理,不过,我们也可以选择在add方法上声明异常,这是与代码4有所不同的地方。这是因为,代码6中的add方法并非继承而来,因此可以声明异常,而代码4中的run方法是从Runnable接口继承来的,而父类方法没有异常声明,子类复写该方法同样也不能声明异常。
3) 问题解决
既然发生了线程安全问题,我们同样可以采用同步代码块来解决。我们对代码6中的Bank类add方法进行修改:
代码7:
1号顾客-----sum = 100
1号顾客-----sum = 200
1号顾客-----sum = 300
2号顾客-----sum = 400
2号顾客-----sum = 500
2号顾客-----sum = 600
这样就解决了线程安全问题。
4) 同步函数
如果我们观察一下代码5中Bank类的add方法就可以发现,该方法中仅有的两个语句均是操作共享数据的,而在代码7中将这两个语句都封装进了同步代码块中,那么我们就可以理解为,整个方法都需要被同步,此时我们就可以将synchronized关键字直接添加到方法声明中,就像下面的代码,
代码8:
至此,我们学习了两种实现同步的方式——同步代码块和同步函数。那么该如何选择呢?大家只要明确一点:如果某个方法中的所有语句均涉及对共享数据的操作,那么可以使用同步函数以提高代码阅读性;否则,就只需要将涉及操作共享数据的那一部分代码封装进同步代码块即可,以便提高程序执行效率。当然也可以将同步代码块的代码单独封装为一个同步函数也是可以的。
1) 同步函数的锁
首先我们直接给出结论:同步函数的锁是该方法所属类的当前对象——也就是this关键字指向的对象。大家可以这样理解:当某个类的非静态成员方法之间互相访问时,方法名前通常省略了关键字this,它是所属对象的引用,因此同步函数的锁就是this引用指向的对象。我们可以通过下面的代码进行证明。
需求:模拟售票程序。
分析:开启两个线程,通过一个标记,迫使他们分别执行同步代码块和同步函数,先后设置同步代码块的“锁”对象为当前对象和其他对象(例如Object对象),分别观察两种情况下是否产生线程安全问题。如果同步函数的“锁”是当前对象,那么向同步代码块中传入当前对象时即使人为造成线程冻结也不会产生线程安全问题。
实现方式:首先需要在Tickets类内定义一个布尔类型的私有成员变量flag,表示标记,默认初始值为true。其次,再定义一个操作车票数的同步函数sellTickets。run方法中的执行流程为:首先通过if语句判断标记,如果标记为true,执行定义在同步代码块内车票数操作语句;如果标记为false,则调用sellTickets方法。在首先获得执行权的线程执行同步代码块以后,通过手动修改标记位false,令第二个线程执行同步函数。注意:开启一号线程以后,为防止其还未开始执行run方法前,主线程抢先将标记置为false,而导致两个子线程均执行同步函数的可能性,在将标记置为false之前暂时冻结主线程,待一号子线程获得执行权并开始执行run方法后,继续运行主线程。
代码:
代码9:
为了直观地证明这一点,我们将代码9稍作修改:将sellTickets方法定义为静态方法,当然该方法访问道德count变量也要被修饰为静态。同时将同步代码块的“锁”置为当前对象。代码如下,
代码10:
我们在《面向对象17:Object类》这篇博客中提到,当某个类被加载进内存的同时会创建一个字节码文件对象(Class对象,用于封装该类的一些信息),所以在初始化某个类,并加载其静态成员时,内存中唯一存在的对象就是字节码文件对象,它同时也是静态同步函数的“锁”。
为此,我们将代码10中Tickets类同步代码块的传入对象改为Tickets.class(表示该类的字节码文件对象),再次运行并观察结果。代码如下(仅显示同步代码块部分),
代码11:
经多次运行验证以后,为观察到错票现象,线程安全问题的到解决,证明静态同步函数的锁就是字节码文件对象。
思路:其中整数j相当于是由多个线程同时操作的共享数据,很容易出现线程安全问题,比如,某个线程对j刚刚完成增加操作,还没来及的打印,执行权就被另一个线程抢去并对j进行了减法操作,最终加减两个线程打印的j值是一样的。为了避免发生诸如此类的线程安全问题,应该将所有对j进行任何操作的代码都封装到同步代码块中,这是最为基本的思路。
对于共享数据本身,以及对操作共享数据代码的设计,可以有以下三种设计方式:
方式一:将共享数据封装在另外一个对象中,然后将这个对象逐一传递给各个Runnable对象。每个线程对共享数据的操作方法也分配到那个对象身上去完成。这样容易实现针对该数据进行的各个操作的互斥和通信。
方式二:将这些Runnable对象作为某一个类中的内部类,共享数据作为这个外部类中的成员变量,每个线程对共享数据的操作方法也分配给外部类,以便实现对共享数据进行的各个操作的互斥和通信,作为内部类的各个Runnable对象调用外部类的这些方法。
方式三:将上述两种方式结合起来,把共享数据封装在另外一个对象中,每个线程对共享数据的操作方法也分配到那个对象身上去完成,对象作为这个外部类中的成员变量或方法中的局部变量,每个线程的Runnable对象作为外部类中的成员内部类或局部内部类。
下面我们按照上述三种方式,逐一列出对应的代码。
代码12:
1. 问题的产生
我们通过前面提到的售票的例子进行说明。
代码1:
public class Tickets implements Runnable { private int count = 100; public void run() { while(true) { if(count > 0) System.out.println(Thread.currentThread().getName()+"-----"+count--); } } } public class ThreadDemo { public static void main(String[] args) { Tickets ts = new Tickets(); Thread t1 = new Thread(ts); Thread t2 = new Thread(ts); Thread t3 = new Thread(ts); Thread t4 = new Thread(ts); t1.start(); t2.start(); t3.start(); t4.start(); } }我们假设这样一个极端情况:起初四个线程的运行都非常正常,直到票数减到1时,0号线程执行到run方法中的if语句进行判断以后,因CPU恰好将执行权分配给了1号线程而进入阻塞状态,而1号线程在运行过程中也恰好在if语句出进入阻塞状态,以此类推,当2号和3号线程都进入阻塞状态以后,CPU又恰好将执行权分配给了0号线程,此时0号线程执行了输出语句:打印线程号和剩余票数——1,而后票数变为0。
正常情况下,当票数为0时应停止程序的运行,但是由于1、2、3号线程均具备执行资格,所以在获得执行权以后将继续执行各自的输出语句,打印余票数量——0、-1、-2。在这种情况下就出现了问题。
2. 问题现象模拟
为了能让大家能够真实观察到上述问题的发生,我们对上述代码1进行一定地修改——在if判断语句之后,输出语句之前调用Thread类的静态方法——sleep,令线程进入冻结状态,以此来模拟CPU随机分配执行权的现象。
注意:由于sleep方法对外声明了InterruptedException异常,所以要对该异常进行处理。但是Tickets类的run方法是通过复写Runnable接口的run方法而来,而Runnable接口的run并未声明异常,因此其子类的run方法也不能声明异常。因此,只能在方法内就地try-catch处理。关于InterruptedException详细说明,请参考后面的博客。
代码2:
class Tickets implements Runnable { private int count = 100; public void run() { while(true) { if(count> 0) { /* 通过sleep方法模拟CPU在不同线程见切换的现象。此处令线程冻结10毫秒 为方便演示并未有针对性的接收异常对象,并省略了处理代码 */ try{Thread.sleep(10);}catch(Exceptione){} System.out.println(Thread.currentThread().getName()+"-----"+count--); } } } } class ThreadDemo2 { public static void main(String[] args) { Tickets ts = new Tickets(); Thread t1 = new Thread(ts); Thread t2 = new Thread(ts); Thread t3 = new Thread(ts); Thread t4 = new Thread(ts); t1.start(); t2.start(); t3.start(); t4.start(); } }当运行上述代码时,就会出现如下结果:
Thread-1_____-1
Thread-0_____0
Thread-0_____-2
出现了负数和零票数。而这种现象是绝对不能在现实生活中发生的。
那么我们上面描述的问题就是:线程的安全问题。这类问题最为棘手的地方在于:项目开发完成以后在测试阶段可能并没有检测出问题,但是一旦用户实际使用时就有可能因各种状况的发生而产生类似的线程安全问题。
3. 思考与解决
1) 问题产生原因
那么产生上述线程安全的问题的原因是:在多个线程在共享一个数据资源(例如上述车票对象中的车票数量成员变量)的前提下,当其中一个线程还没有执行完毕操作数据资源的多条语句之前,就因CPU分配执行权的随机性导致另一个线程执行数据资源操作语句,最终造成共享数据错误。
解决这个问题的方法很简单:只要保证每个线程在执行完操作共享数据的语句之后,再另其他线程对数据资源进行操作即可避免错误的发生。换句话说在某个线程执行完毕以前,其他线程不可以执行操作共享数据额语句。
2) 解决方法
Java语言中为解决线程安全问题,提供了专业的解决方法——同步代码块。其书写格式如下:
代码3:
synchronized(对象) { //需要被同步的代码 }说明:
(a) synchronized关键字后的小括号内需要传递一个对象,目前先暂时理解为任意对象即可。
(b) run方法中定义的是需要由子线程执行的全部代码,但是能否将这些代码全部放入同步代码块呢?答案是否定的,因为同步代码块的执行需要额外耗费一定的系统资源,因此尽量减少同步代码块内的代码对于提高程序的执行效率是非常有必要的。这也是同步代码块在提高线程安全的同时,所付出的代价。那么如何决定哪些代码需要被同步,而哪些代码不需要呢?技巧就是:同步代码块内应只包含,操作共享数据的语句。以代码2为例,if判断语句以及后接的输出语句使用了共享数据count,而之前的while循环并没有。
在代码2内加入了同步代码块,并通过this关键字传递当前对象,
代码4:
class Tickets implements Runnable { private int count = 100; public void run() { while(true) { //同步代码块。传递当前对象。 synchronized(this) { if(count> 0) { try{Thread.sleep(10);}catch(Exceptione){} System.out.println(Thread.currentThread().getName()+"_____"+count--); } } } } } class ThreadDemo3 { public static void main(String[] args) { Tickets ts = new Tickets(); Thread t1 = new Thread(ts); Thread t2 = new Thread(ts); Thread t3 = new Thread(ts); Thread t4 = new Thread(ts); t1.start(); t2.start(); t3.start(); t4.start(); } }通过结果说明:即使人为造成线程冻结(sleep方法),但是由于同步代码块的作用,也并没有错票的出现。
4. 同步代码块
1) 原理
我们就以上述代码4为例,说明同步代码块的原理。同步代码块就好比是一道门,一道通往共享数据的门,过了这道门就可以操作共享数据。而synchronized关键字后需要传入的对象就好比是门上的锁。而这个锁有两个状态(或称为标记位)——锁上和没锁上。
当没有任何线程执行过同步代码块中的代码之前,锁的默认状态是没有锁上的,那么第一个执行同步代码块的线程,比如0号线程,就可以进入到“门”里面,但它并不急于执行里面的代码,而是先将“锁”锁上,保证其他线程无法“进入”,然后才开始执行代码。
当0号线程执行到sleep语句并进入冻结状态后,CPU将执行权分配给了其他线程,比如1号线程。此时1号线程开始执行run方法,但是由于此时同步代码块上是“锁着的”,因此只能止步于同步代码块,其他线程即使分配到执行权也是如此。只有等到,0号线程唤醒以后,再由CPU分配到执行权,并执行完毕同步代码块中的语句以后,“锁”才能被再次打开,这样其他线程才能陆续执行同步代码块中的代码,而且也要重复“上锁”与“开锁”动作。
这样通过上述“锁”的机制,就保证了,即使在人为剥夺线程执行权,甚至执行资格的情况下,也能使得同步代码块在同一时间只由一个线程执行,避免线程安全问题的产生。
那么通过上述同步技术实现操作共享数据代码线程安全的思想,也可以称作实现代码的原子性。因为我们可以认为原子是不可分割的最小物质单元(事实并非如此,因为原子还可以继续分为原子核和核外电子,这里仅仅是一个类比),因此称代码具有原子性表示,这一段代码的执行将不会被打断。当实现了某段代码的原子性以后,当有多个线程同时来执行这段代码时,多个线程之间就形成了互斥的效果——同一时间只能有一个线程执行。
2) 前提
虽然同步代码块很好的避免了线程安全问题,但却不利于程序运行效率的提高,因此,应在符合一定前提条件的情况下使用:
前提一:必须要有两个及以上的线程运行,包括主线程。如果是单线程,那也就不存在线程安全问题了。
前提二:必须是多个线程使用同一个“锁”。如果说两个线程同步代码块中传入的是两个不同的对象,那么即使当第一个线程执行同步代码块时“上了锁”,却并没有上第二个线程同步代码块的锁,因为这是两个不同锁。所以使用两个不同的“锁”是不能保证两段代码的线程安全的。
我们可以以上厕所为例进行类比说明。比如,一个厕所有两个坑,代表两个不同的操作共享数据的代码。坑本身没有锁,那么为了安全的上厕所呢,每进来一个人就要把厕所的门锁上,这样一来无论谁进来使用哪个坑都能保证安全。再比如,一个厕所还是有两个坑,但是厕所本身没有锁,因此为了上厕所的安全,两个坑各自有一把锁。假如甲进厕所使用A坑,将A坑的锁锁上了,那么这能保证B坑的锁也锁上吗?当然不能。因此此时乙就可以进到B坑,此时就出现了两个线程同时对同一个共享数据进行操作的现象。
这两个前提也适用于其他同步实现机制,包括我们后面将要讲到的同步函数和静态同步函数。
3) 优缺点
至此我们就可以总结出同步代码块的优缺点:
优点:解决了线程安全问题。
弊端:由于每次都要判断“锁”,消耗了系统资源,使程序运行效率下降。
5. 同步函数
1) 示例
我们再通过模拟一个现实生活中的事例,来复习同步代码块,并引入新的内容——同步函数。
需求:模拟两个储户同时在一个银行(限定在一个金库)存款的动作。每个储户共存3次,每次100元。
分析:这个事例中的共享资源是银行金库,或者称为存款总额。两个储户就是两个线程,同时向同“一个”金库存钱,那么为了保证唯一性在整个代码中只能创建一个银行对象,并使这个银行对象与两个储户对象产生联系,以便储户可以操作银行的存款总额。
实现方式:定义银行类——Bank,其内部定义表示存款总额的私有整型变量sum和增加存款的方法add。定义实现了Runnable接口的储户类Custom,构造方法中初始化一个银行对象。run方法中使用for循环分3次进行存款。在主函数中首先创建唯一的一个银行对象,再创建两个Thread对象,构造方法中传入两个储户对象和储户名称,两个储户对象均传入唯一的银行对象。最后开启线程。
代码:
代码5:
/* 定义银行类 其内部定义一个表示存款总额的整型私有成员变量sum 定义增加存款总额的方法add,每次增加存款就打印一次存款总额 */ class Bank { //银行的存款总额 private int sum; //增加总额的方法 public void add(int n) { sum+ = n; //同时打印操作存款总额的客户名称(线程名称)和增加数额 System.out.println(Thread.currentThread().getName()+"-----sum= "+sum); } } /* 定义储户类 由于要模拟多个储户同时存款的动作,该类需要实现Runnable接口 */ class Custome implements Runnable { private Bank b; Custom(Bankb) { /* 为每个储户对象初始化一个银行对象,以供操作存款 但是每个储户对象中的银行对象必须是同一个 */ this.b = b; } public void run() { //每个客户共存3次,每次100元 for(int x = 0; x<3; x++) b.add(100); } } public class ThreadDemo4 { public static void main(String[] args) { //为所有储户对象创建唯一的银行对象,保证多个储户(线程)共享一个数据资源 Bank b = new Bank(); Thread t1 = new Thread(new Custom(b), "1号顾客"); Thread t2 = new Thread(new Custom(b), "2号顾客"); t1.start(); t2.start(); } }运行结果为:
1号顾客-----sum = 100
2号顾客-----sum = 200
1号顾客-----sum = 300
2号顾客-----sum = 400
1号顾客-----sum = 500
2号顾客-----sum = 600
从结果上看确实实现了两个储户同时存款的动作,存款总金额为600元。
2) 问题思考
我们初步完成了上述事例的需求,接下来就需要思考这段代码是否存在线程安全为问题。我们可以从以下三个方面思考:
(a) 明确哪些代码是多线程执行代码
显然,Custom类中run方法内的代码均是多线程执行代码,这当然也包括Bank类的add方法代码。
(b) 明确共享数据
最直观来说Bank对象是被两个线程所“共享”的,而对象中的sum成员变量就是被“共享”操作的数据资源。
(c) 明确多线程执行代码中哪些语句是操作共享数据的。
代码5中Bank类的add方法直接操作了sum成员变量,并且涉及语句有两句,这就最有可能造成线程安全问题:当某个线程刚执行完第一句,CPU就将执行权分配给了其他线程,最终导致问题的出现。
那么同样为了方便大家观察问题现象,我们再次通过sleep方法模拟上述(c)中可能会发生的问题。对代码5中的add方法进行修改:
代码6:
class Bank { private int sum; public void add(int n) { sum+ = n; //为模拟线程安全问题,故意在此处冻结线程。同样对代码进行了简化处理。 try{Thread.sleep(10);}catch(Exceptione){} System.out.println(Thread.currentThread().getName()+"-----sum= "+sum); } }同样执行ThreadDemo4,运行结果可能为:
2号顾客-----sum = 200
1号顾客-----sum = 200
2号顾客-----sum = 400
1号顾客-----sum = 500
2号顾客-----sum = 600
1号顾客-----sum = 600
这样就出现了“重复存款”的错误现象,说明代码5是存在线程安全问题的。
小知识点1:
我们在代码6中选择对sleep调用语句进行就地try-catch处理,不过,我们也可以选择在add方法上声明异常,这是与代码4有所不同的地方。这是因为,代码6中的add方法并非继承而来,因此可以声明异常,而代码4中的run方法是从Runnable接口继承来的,而父类方法没有异常声明,子类复写该方法同样也不能声明异常。
3) 问题解决
既然发生了线程安全问题,我们同样可以采用同步代码块来解决。我们对代码6中的Bank类add方法进行修改:
代码7:
class Bank { private int sum; public void add(int n) { //为操作共享数据的代码添加同步代码块,传入对象依旧是当前对象 synchronized(this) { sum+ = n; //为模拟线程安全问题,故意在此处冻结线程 try{Thread.sleep(10);}catch(Exceptione){} System.out.println(Thread.currentThread().getName()+"-----sum= "+sum); } } }运行ThreadDemo4类,执行结果可能为:
1号顾客-----sum = 100
1号顾客-----sum = 200
1号顾客-----sum = 300
2号顾客-----sum = 400
2号顾客-----sum = 500
2号顾客-----sum = 600
这样就解决了线程安全问题。
4) 同步函数
如果我们观察一下代码5中Bank类的add方法就可以发现,该方法中仅有的两个语句均是操作共享数据的,而在代码7中将这两个语句都封装进了同步代码块中,那么我们就可以理解为,整个方法都需要被同步,此时我们就可以将synchronized关键字直接添加到方法声明中,就像下面的代码,
代码8:
class Bank { private int sum; //将synchronized关键字添加到方法声明中,表示整个方法都需要被同步 public synchronized void add(int n) { sum+ = n; try{Thread.sleep(10);}catch(Exceptione){} System.out.println(Thread.currentThread().getName()+"-----sum= "+sum); } }这样同样可以解决线程安全问题。
至此,我们学习了两种实现同步的方式——同步代码块和同步函数。那么该如何选择呢?大家只要明确一点:如果某个方法中的所有语句均涉及对共享数据的操作,那么可以使用同步函数以提高代码阅读性;否则,就只需要将涉及操作共享数据的那一部分代码封装进同步代码块即可,以便提高程序执行效率。当然也可以将同步代码块的代码单独封装为一个同步函数也是可以的。
6. 锁
无论是同步代码块还是同步函数,他们之所以可以实现同步机制,关键在在于他们都有一把“锁”,我们在前面的内容中提到,同步代码块的锁是传递的对象,那么同步函数的锁又是什么呢?1) 同步函数的锁
首先我们直接给出结论:同步函数的锁是该方法所属类的当前对象——也就是this关键字指向的对象。大家可以这样理解:当某个类的非静态成员方法之间互相访问时,方法名前通常省略了关键字this,它是所属对象的引用,因此同步函数的锁就是this引用指向的对象。我们可以通过下面的代码进行证明。
需求:模拟售票程序。
分析:开启两个线程,通过一个标记,迫使他们分别执行同步代码块和同步函数,先后设置同步代码块的“锁”对象为当前对象和其他对象(例如Object对象),分别观察两种情况下是否产生线程安全问题。如果同步函数的“锁”是当前对象,那么向同步代码块中传入当前对象时即使人为造成线程冻结也不会产生线程安全问题。
实现方式:首先需要在Tickets类内定义一个布尔类型的私有成员变量flag,表示标记,默认初始值为true。其次,再定义一个操作车票数的同步函数sellTickets。run方法中的执行流程为:首先通过if语句判断标记,如果标记为true,执行定义在同步代码块内车票数操作语句;如果标记为false,则调用sellTickets方法。在首先获得执行权的线程执行同步代码块以后,通过手动修改标记位false,令第二个线程执行同步函数。注意:开启一号线程以后,为防止其还未开始执行run方法前,主线程抢先将标记置为false,而导致两个子线程均执行同步函数的可能性,在将标记置为false之前暂时冻结主线程,待一号子线程获得执行权并开始执行run方法后,继续运行主线程。
代码:
代码9:
class Tickets implements Runnable { private int count = 100; //定义标记,默认为true private boolean flag = true; //为演示错误现象,创建一个Object对象,用于同步代码块 private Object obj = new Object(); public void run() { //如果标记为true,则通过同步代码块对count进行操作 if(flag) { while(true) { synchronized(obj) { if(count> 0) { //为方便观察错误现象调用sleep,同步函数也做相同操作 try{Thread.sleep(10);}catch(Exceptione){} System.out.println(Thread.currentThread().getName()+"____________"+count--); } } } } //如果标记为false,则通过同步函数对count进行操作 else { while(true) sellTickets(); } } //为使两个线程分别执行同步代码块和同步函数,提供标记修改方法 public void setFlag(boolean flag) { this.flag = flag; } //定义同步函数,同样操作count变量 private synchronized void sellTickets() { if(count> 0) { try{Thread.sleep(10);}catch(Exceptione){} System.out.println(Thread.currentThread().getName()+"______"+count--); } } } class ThreadDemo5 { public static void main(String[] args) { Tickets ts = new Tickets(); Thread t1 = new Thread(ts); Thread t2 = new Thread(ts); t1.start(); //暂时冻结主线程,防止两个子线程均在同步函数 try{Thread.sleep(10);}catch(Exceptione){} /* 为使两个线程分别执行同步代码块和同步函数 当第一个线程开始执行同步代码块后 将标记改为false,迫使另一个线程执行同步函数 */ ts.setFlag(false); t2.start(); } }我们将上述代码执行两次,对比观察运行结果。首先将同步代码块的“锁”置为Object对象,此时运行结果出现错误:出现非正票数;然后,将同步代码块的“锁”置为当前对象(this),错票现象消失。那么这一错误现象正是因为未能满足使用同步机制的两个前提之一而导致的:没有使用同一把“锁”。这也就很好的证明了——同步函数的“锁”是this关键字指向的当前对象。
7. 静态同步函数
我们都知道普通函数可以被静态修饰,当然同步函数也是可以被静态修饰的。但是,静态函数因其特殊性:随着类的加载而加载,并可以通过类名调用,所以静态方法内部是没有当前对象引用的(也就是this),因此静态同步函数的锁不可能是当前对象。为了直观地证明这一点,我们将代码9稍作修改:将sellTickets方法定义为静态方法,当然该方法访问道德count变量也要被修饰为静态。同时将同步代码块的“锁”置为当前对象。代码如下,
代码10:
class Tickets implements Runnable { private static int count = 100; private boolean flag = true; public void run() { if(flag) { while(true) { //证明静态同步函数的“锁”不是当前对象 synchronized(this) { if(count> 0) { try{Thread.sleep(10);}catch(Exceptione){} System.out.println(Thread.currentThread().getName()+"____________"+count--); } } } } else { while(true) sellTickets(); } } public void setFlag(boolean flag) { this.flag = flag; } //将该方法定义为静态 private static synchronized voidsellTickets() { if(count> 0) { try{Thread.sleep(10);}catch(Exceptione){} System.out.println(Thread.currentThread().getName()+"______"+count--); } } } class ThreadDemo6 { public static void main(String[] args) { Tickets ts = new Tickets(); Thread t1 = new Thread(ts); Thread t2 = new Thread(ts); t1.start(); try{Thread.sleep(10);}catch(Exceptione){} ts.setFlag(false); t2.start(); } }运行上述代码,发现又出现了线程安全问题——打印非正票数。这就说明静态同步函数的“锁”并非是当前对象。
我们在《面向对象17:Object类》这篇博客中提到,当某个类被加载进内存的同时会创建一个字节码文件对象(Class对象,用于封装该类的一些信息),所以在初始化某个类,并加载其静态成员时,内存中唯一存在的对象就是字节码文件对象,它同时也是静态同步函数的“锁”。
为此,我们将代码10中Tickets类同步代码块的传入对象改为Tickets.class(表示该类的字节码文件对象),再次运行并观察结果。代码如下(仅显示同步代码块部分),
代码11:
//将同步代码块的锁置为本类字节码文件对象 synchronized(Tickets.class) { if(count> 0) { try{Thread.sleep(10);}catch(Exceptione){} System.out.println(Thread.currentThread().getName()+"____________"+count--); } }
经多次运行验证以后,为观察到错票现象,线程安全问题的到解决,证明静态同步函数的锁就是字节码文件对象。
8. 线程安全练习
题目:设计4个线程,其中两个线程每次对j增加1,另外两个线程对j每次减少1。思路:其中整数j相当于是由多个线程同时操作的共享数据,很容易出现线程安全问题,比如,某个线程对j刚刚完成增加操作,还没来及的打印,执行权就被另一个线程抢去并对j进行了减法操作,最终加减两个线程打印的j值是一样的。为了避免发生诸如此类的线程安全问题,应该将所有对j进行任何操作的代码都封装到同步代码块中,这是最为基本的思路。
对于共享数据本身,以及对操作共享数据代码的设计,可以有以下三种设计方式:
方式一:将共享数据封装在另外一个对象中,然后将这个对象逐一传递给各个Runnable对象。每个线程对共享数据的操作方法也分配到那个对象身上去完成。这样容易实现针对该数据进行的各个操作的互斥和通信。
方式二:将这些Runnable对象作为某一个类中的内部类,共享数据作为这个外部类中的成员变量,每个线程对共享数据的操作方法也分配给外部类,以便实现对共享数据进行的各个操作的互斥和通信,作为内部类的各个Runnable对象调用外部类的这些方法。
方式三:将上述两种方式结合起来,把共享数据封装在另外一个对象中,每个线程对共享数据的操作方法也分配到那个对象身上去完成,对象作为这个外部类中的成员变量或方法中的局部变量,每个线程的Runnable对象作为外部类中的成员内部类或局部内部类。
下面我们按照上述三种方式,逐一列出对应的代码。
代码12:
//方式一 //共享数据类,单独封装为一个类 public class Resource { private int j = 100; //由于方法中的所有代码均需要被同步,因此直接定义为同步方法 publicsynchronized void increment() { Thread thread = Thread.currentThread(); System.out.println(thread.getName()+" "+(j++)+" -->"+j); } public synchronized void decrement() { Thread thread = Thread.currentThread(); System.out.println(thread.getName()+" "+(j--)+" -->"+j); } } //负责对共享数据进行加法操作的Runnable实现类 public class Adder implements Runnable { private Resource resource; Adder(Resource resource){ this.resource = resource; } @Override public void run() { for (int i = 0; i < 10; i++) { resource.increment(); } } } //负责对共享数据进行减法操作的Runnable实现类 public class Reducer implements Runnable { private Resource resource; Reducer(Resource resource) { this.resource = resource; } public void run(){ for (int i = 0; i < 10; i++) { resource.decrement(); } } } //测试类 public class MultiThreadShareDataTest { public static void main(String[] args) { Resource resource = new Resource(); for(int i=0; i<2; i++) { new Thread(new Adder(resource)).start(); new Thread(new Reducer(resource)).start(); } } }代码13:
//方式二 public class MultiThreadShareDataTest2 { //直接将共享数据定义为测试类的成员变量 private int j = 100; public static void main(String[] args) { MultiThreadShareDataTest2 mtsdt = new MultiThreadShareDataTest2(); for(int i=0; i<2; i++) { new Thread(new Runnable() { @Override public void run() { for (int j = 0; j < 10;j++) { mtsdt.increment(); } } }).start(); new Thread(new Runnable() { @Override public void run() { for (int j = 0; j < 10;j++) { mtsdt.decrement(); } } }).start(); } } //将操作共享数据的方法也定义为测试类的成员方法 public synchronized void increment() { Thread thread = Thread.currentThread(); System.out.println(thread.getName()+" "+(j++)+" --> "+j); } public synchronized void decrement() { Thread thread = Thread.currentThread(); System.out.println(thread.getName()+" "+(j--)+" -->"+j); } }代码14:
public class MultiThreadShareDataTest3 { //将共享数据定义为测试类的成员内部类 private class Resource { private int j = 100; public synchronized void increment() { Thread thread = Thread.currentThread(); System.out.println(thread.getName()+" "+(j++)+" -->"+j); } public synchronized void decrement() { Thread thread = Thread.currentThread(); System.out.println(thread.getName()+" "+(j--)+" -->"+j); } } public static void main(String[] args) { Resource resource = new MultiThreadShareDataTest3().new Resource(); //将四个线程定义为内部类 for(int i=0; i<2; i++) { new Thread(new Runnable(){ @Override public void run() { for(int i=0; i<2; i++) { resource.increment(); } } }).start(); new Thread(new Runnable(){ @Override public void run() { for(int i=0; i<2; i++) { resource.decrement(); } } }).start(); } } }以上三个代码根据三种不同的思路实现了同一个需求——都能够在保证线程安全的前提下,创建多个线程,令其对共享数据进行不同的操作。大家可以自行尝试执行以上代码。
相关文章推荐
- 黑马程序员--Java学习笔记之多线程(自定义线程的两种方式对比、线程状态、线程安全)
- 黑马程序员——多线程(线程安全、线程间通信、1.5中的Lock)总结2
- 黑马程序员——多线程--线程的创建方式和线程安全的简单介绍
- 黑马程序员_多线程(线程安全和通信)
- 黑马程序员——Java基础之多线程
- 黑马程序员_java08_多线程
- 黑马程序员————————多线程(死锁的原理)
- 黑马程序员---java基础多线程
- 黑马程序员-JAVA-多线程使用初探
- 多线程学习总结(九)——线程安全之线程间的通信
- 黑马程序员_多线程
- 黑马程序员--Java编程之多线程
- 黑马程序员 java多线程总结
- 黑马程序员——多线程
- 黑马程序员--学习日记之多线程
- 黑马程序员----------多线程
- 黑马程序员-----多线程
- 黑马程序员——Java 多线程
- 黑马程序员 —— Java多线程1 (第十一天)
- 黑马程序员-JAVA基础-多线程概念与创建