HIT软件构造第七章知识点总结

本文深入探讨Java中的并发编程概念,包括进程与线程的区别,Java中创建线程的三种方法,以及线程间的数据共享和竞争问题。文章还详细介绍了线程安全的实现策略,如限制数据共享、使用不可变类型、使用线程安全的类,以及锁与同步机制。最后,讲解了如何避免死锁和使用wait(), notify(), notifyAll()方法。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >


本章主要的内容为线程相关的内容。

一.并发编程

1.进程与线程

  并发编程中的模块类型:进程/线程。
  进程:其实就是正在运行的一个程序。每个进程都有自己对应的虚拟内存,之间互不影响,每个进程之间没有耦合或者是非常松的耦合,之间通过消息传递进行协作。运行一个进程时,仿佛这个进程独占计算机的全部资源一样。一般而言,一个java虚拟机同时只运行一个进程。
  线程:一个进程中可能有多个线程,多个线程之间可以共享内存(但要属于这个进程),但每个线程都有自己调用栈和寄存器等等,如下图所示,单线程vs多线程。
在这里插入图片描述

2.JAVA中的线程

  main方法开启的是主线程,可以创建其他的线程。
  如果我们想创建新线程,则以下三种方法:
  1.创建一个类,继承Thread类,并且必须覆写其中的run方法(其他的属性和方法可以自己随便加),这样创建这个类的对象时,调用start()方法即可开启新线程(注意不是run方法!!)。
  2.创建一个类,实现Runnable接口,并且同样的必须覆写其中的run方法,这样调用(new Thread(这个类的对象)).start()即可开启新线程。
  3.采用匿名类,类似于监听器的逻辑。但实际上也是第二种方法。

3.交错和竞争

  类似于进程,即使有多个线程,在一个核内,也只能同时有一个线程执行。但是都已经启动的线程的执行顺序,哪个先执行完等等是随机的,是OS决定的,不受程序控制(甚至某个线程内所谓的代码顺序也可能是不满足的,因为底层的代码逻辑顺序与高级语言代码顺序可能不同,然后运行到一半被其他线程打断…)。
  如果对于线程A和B,只要它们会存在如果执行顺序(包括每个运行一部分然后不断切换等等)不同则导致执行结果不同的情况,则称它们之间存在着竞争。竞争经常会导致难以查找的烦人bug.
  下图是最极端的例子,我们对这两个方法各调用一次,但methodA和methodB属于不同的线程,而乘法要经历三个操作:取出当前的x值,对取出的这个值进行乘法操作,把计算出的值写回原处。由于这些步骤之中任意的地方都可以被另外一个进程打乱,因此可能的结果为5,6,10,30。(例如6出现在x×3的操作进行完第二步算出6但是被打断,进而去执行x×5操作,执行完后再进行x×3的第三步,这样直接把6带回了x的地址,从而覆盖了前面的操作)
在这里插入图片描述

4.避免交错的一些方法

  java中自带了控制线程行为的方法。
  首先Thread.sleep(x)方法可以让当前进程休眠x毫秒,在接下来的x毫秒不会运行。
  其次Thread.join()方法可以让对应的进程保持执行,直到其执行结束。
  但接下来才是主角:interrupt方法,共有三个类似的方法。总的来说,就是设置某个线程的中断状态,而这个中断状态就要和上面的两个方法一起配合使用:如果某个线程在某一时刻变为中断状态,同时它这时正处于sleep()方法或wait()方法或join()方法控制,则会抛出InterruptedException异常,从而进行处理;而如果就是正常运行而不处于这些特殊方法的控制下,则中断没有效果,仅仅是设置个状态罢了,即使以后这个线程调用了sleep()方法等也没有任何影响。
在这里插入图片描述

二.线程安全的实现

  线程安全的定义为:首先是在何种调度情况下,都不违反规约(即正确性);其次就是,不需要用户端的使用者去满足与保护线程安全相关的义务,也就是说,用户甚至其实不用知道这个和线程有联系,就直接拿来用就好。
  因此迭代器不是线程安全的,删除元素的过程中结果是未知的。
  下面主要由简单到复杂,来介绍实现线程安全的方法。

1.限制数据共享

  首先对于一个java的虚拟机(代表着一个进程),其中有一个和多个,每个线程的堆是共享的,而栈是每个线程私有的。
  栈中的内容:所有线程的局部变量(包括对对象的引用)
  堆中的内容:所有对象本身以及所有静态的方法,变量,类等等。
  因此可见,我们这里要考虑的就是堆中的内容如果可以被共享可能会出现线程不安全的情况。我们这种方法就是,不使用这种可以共享的数据。但是显然,这种很多时候是难以做到的。就比如一个类,可能内部使用了一些匿名类来实现了多线程,那对应的方法中就都可以共享堆中的这个对象的rep。
  当然,这里有一个误区,就是不是说是堆中的内容就一定会出现线程不安全,前提是很多线程都有引用去访问这一块内容。而如下图所示,result是一个引用(它自身在自己线程的栈中),因此其他线程没有对应的引用;而创建的对象虽然是在堆中,但是他们都是new出来的,因此地址不一样;他们只有对自己对象的引用,因此它们不存在共享全局变量的情况。
在这里插入图片描述
  对比之下,下图的rep就成为了全局变量(不论它的修饰词),因为对象的多个方法各启动一个线程后,可以共享这个对象的rep。
在这里插入图片描述

2.使用不可变类型

  第一种策略要求不能有全局变量,这个太苛刻了,这个策略中要求全局变量是不可变类型即可。但由于造成冲突的本身原因,是任何使得全局变量产生改变的因素;一个不可变类型有可能会有beneficent mutation,隐藏在构造器和生产器甚至访问器中,从而也会出现冲突。因此上述情况也要避免。
  因此可见,在这种策略下,如果我们说一个类是线程安全的,则有以下四点要求:
  1. 这个类没有变值器。
  2. 这个类的所有字段都是private和final的。
  3. 这个类不存在表示泄露。
  4. 这个类没有beneficent mutation.
  其中前三条说明是不可变类,第四条是额外要求。

3.使用线程安全的类

  这里所说的线程安全的类和上文不一样,就是我们就是想用不可变类,同时又要保证线程安全。java库中对每个对应的可变类,都有一个新的功能一样,但是保证了线程安全的类。有的是通过装饰者模式(这种模式要求不能有残留引用指向之前没有被装饰的内容),有的是通过正常的模式。
在这里插入图片描述
  例如String StringBuffer和StringBuilder,它们三个分别是不可变类型,可变但是线程安全,以及可变但不是线程安全的。虽然功能一样,但越后面的越灵活,速度快,但相应的安全性就会降低。
  但是问题在于,它们内部避免线程安全的形式是,使得操作都是原子的:即每个操作是不可分割的,从而保证了每个线程进行一个操作的时候不会被打扰。但问题是,如果是遍历这种进行多次操作,那还是可以被分割的,从而造成了线程之间的竞争。因此也可以说即使使用了线程安全的集合类,面对迭代器操作加上put或者remove这种变值器,还是会出现线程不安全的情况(说白了,最开始举的例子到现在还没有解决)。

4.锁与同步

A.新的机制

  既然上面的方法都无法解决,我们可以想到如下方法:在迫不得已的时候,面对冲突,我们强制性的把并行变为串行,也就是使得数据有一把锁,谁有锁才能打开去使用,其他线程只能等待,直到这个使用的线程把锁交出来为止。
  因此线程的基本操作就是增加了请求锁,以及释放锁。

B.锁的实现

  java中每个对象都有自己的一把锁,是内嵌的。我们分别对对象方法的语句块语句块加锁,对对象方法整个加锁,对静态方法或其中一部分语句块加锁三种方式进行讨论。
  不管怎样,加锁的结果就是加锁的内容采用happened-before机制,保证了前面线程对这块内容的操作结果对下一个线程全是可见的(同时也说明了一定是前面线程都操作完毕了),确保内存一致性,同时说明了锁确实保证了线程安全。
  1.对对象方法的语句块加锁:语法为,在需要加锁的地方使用synchronized (对象名){需要加锁的语句块} 来进行加锁。注意,这时对语句块的锁取决于这个对象名,也就是这个锁是属于这个对象的。如果很多线程都是用的这个对象来执行这个语句块,那就是共用一把锁,只有共用一把锁的时候一个线程运行这块语句块的时候其他线程是无法运行这个语句块的;而如果多个线程各自有一个对象执行这个语句块,那就相当于是每个线程都有对应的一把锁,互相没有约束了,这个加锁也就没有意义了。
  以下两张图第一张就是多线程使用同一个对象,第二张就是每个线程一个对象。
在这里插入图片描述
在这里插入图片描述
  当然,此时我们也可以把对象名改为this,说明是对这个对象来进行加锁。
  2.对对象方法的加锁:这时就是对整个方法的所有语句都进行加锁,这时用于该方法是属于对象的,因此一定是对this加锁。实现方式其实可以用上面的逻辑:方法名{synchronized (this){方法的全部语句}},也可以采取更一般化的格式,对方法加入修饰词,如下图所示。
在这里插入图片描述
  如果除构造方法外的所有方法都加入synchronized关键字(构造方法本身保证了线程安全,不用考虑),那其实就相当于把这个类变成了第三种策略中所说的线程安全的类。这种设计模式也被成为监视器模式
  同样的,如果多个线程不是同一把锁(不是同一个对象,有的没有实现锁机制等等),那这个方法的锁也就没有意义了。
  3.对静态方法或其代码块的加锁:语法和上面相同,不过由于静态方法是和类关联的,因此一旦加锁,就是使用了和这个类关联的锁,可见这个锁产生的影响更大,但与所有与对象相关的锁均不同,也就是说虽然这个锁影响大,但是使用与对象相关的锁不会受到这个锁的影响。
  由此可见,加锁确实可以提高多线程时的安全性,但是同时会极大的降低性能,因为加锁的地方不仅变成了串行,而且锁换来换去也浪费时间,而且因为happen-before机制,对缓存的利用率也会降低(因为必须将缓存中的内容写入内存等等,才能让下一个线程使用)。因此,我们应该仅在使用多线程,且会出现不安全的代码块进行加锁,不要盲目多加锁

C.原子操作

  被锁约束的操作就是原子操作了。我们可以通过多层次的锁,来实现对一系列操作合并成一个原子操作。例如,有三个加了锁的对象方法A,B,C,和对象x。如果我们需要依次调用ABC,并且要求这个过程是原子的,我们就可以定义一个新的加锁方法D,其中D中依次调用ABC(注意,前提依然是线程调用D时使用的是同一把锁)。
  至此,迭代删除等等这系列操作的不安全性终于解决,我们对整个流程再进行加锁即可。

D.死锁

  当一个线程有多把锁,而且锁与锁不是平行关系,而是有包含关系,就像上文说的那样,一个外部的锁里面有三把内部的锁,就可能出现死锁情况,导致程序无法再执行。
  我们考虑如下情况,对于线程1和线程2,都有两把锁A和B,线程1中锁A在外面锁B在里面,线程2中锁B在外面锁A在里面。那启动这两个线程后,首先可以线程1得到A,线程2得到B开始执行,但执行到线程1需要B且线程2需要A时就出现了问题:他们要想得到锁就要对方释放锁,但对方释放锁必须要得到自己的锁,然而自己还没完成怎么能释放!从而就因此卡住了,这种现象就被称为死锁。
  针对于死锁的情况,我们有以下解决方案。
  1.把锁进行排序,就像上面的例子,我们让这两个线程都是锁A在外面锁B在里面,那死锁就不可能产生了。但问题就是,我们需要定义锁的顺序,严重依赖于rep或输入等等,紧耦合且无法模块化。
  2.在外部加入一把锁,也就是对线程1和2,都加一把锁C,C包含了A和B,这样对于这一套流程,就成为了串行,从而不会出现死锁。但这种方式的缺点就在于严重降低了性能。

E. wait(), notify(), 与notifyAll()

  这三个方法都是Object类的方法,因此所有对象都可以调用这三个方法,对应的逻辑如下。
在这里插入图片描述
  我们可以看出,这三个方法应用条件都是这个线程目前有对象o的锁,否则会抛出异常。然后wait其实就是交出锁进行等待,等待的过程中不去竞争这把锁;等待着一些条件完成后,再让另外的线程调用notify方法去唤醒,此时这个线程仅仅是去竞争这把锁,而不是直接得到锁。从而实现了条件顺序的逻辑。
  wait和sleep的有很多相似之处:它们都是立刻响应interrupt的,而且都要求目前线程拥有这个锁,但区别是,wait是释放锁并且一直等待,sleep不释放锁,只是自己休眠一段时间(这个时间其他线程也是无法得到锁的)。
  最终的逻辑如下图所示。

在这里插入图片描述

三.对线程安全的论证

  我们在对自己的类的注释代码中,如果这个类我们是设计成了保证线程安全,那就要论述如何保证的。通过thread safety argument进行论述。
  其中论述的内容包括先说明我们使用了哪种策略,然后这种策略的具体实现方式要说清;如果是后两者策略的话,还要额外说明对应的操作都是原子的(也就是锁的合理性)。
  注意,这个论述其实是很难的,有的时候还是依赖于外部条件,尤其是第一种策略;因此更推荐用后面的策略,适用范围更广,同时论述中也要把所有的情况尽量说清。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值