线程基础
2021-03-08 09:57:28 12 举报
AI智能生成
多线程基础知识整理
作者其他创作
大纲/内容
线程
线程状态
NEW
初始状态,线程被构建,但是还没有调用start()方法
RUNNABLE
运行状态,Java线程将操作系统中的就绪和运行两种状态笼统地称作“运行中”
BLOCKED
阻塞状态,表示线程阻塞于锁
WITING
等待状态,表示线程进入等待状态,进入该状态表示当前线程需要等待其他线程做出一些特定动作(通知或中断)
TIME_WAITING
超时等待状态,该状态不同于WAITING,它是可以在指定的时间自行返回的
TERMINATED
终止状态,表示当前线程已经执行完毕
线程的状态变迁
子主题
多线程
概念
多线程是进程中的一个执行单元,负责当前进程中程序的执行,一个进程中至少有个一个线程。一个线程中可以有多个线程的,这个应用程序也可以称之为多线程程序。
并发和并行
并行是指两个或者多个事件在同一时刻发生;而并发是指两个或多个事件在同一时刻间隔发生
并行是在不同实体上的多个事件,并发是在同一实体上的多个事件
在一台处理器上“同时”处理多个任务,在多台处理器上同时处理多个任务。如Hadoop分布式集群
好处
提高cpu的利用率
更高的响应
公平使用CPU资源
当前没有进行处理的任务,可以将处理器时间让给其他任务;
占用大量处理时间的任务,也可以定期将处理器时间让给其他任务;
通过对CPU时间的划分,使得CPU时间片可以在多个线程之间切换,避免需要长时间处理对线程独占CPU,导致其它线程长时间等待。
代价
更复杂的设计
共享数据的读取
数据的安全性
线程之间的交互
线程的同步
上下文环境切换
线程切换
cpu需要保存本地数据
程序指针等内容
更多的资源消耗
每个线程都需要内存维护自己的本地栈信息
操作系统也需要资源对线程进行管理维护
线程安全
基本概念
何为竞态条件
当两个线程竞争同一资源时,如果对资源的访问顺序敏感,就称存在竞态条件
导致竞态条件发生的代码区称作临界区
在临界区中使用适当的同步就可以避免竞态条件,如果synchronized或者加锁机制
何谓线程安全
允许被多个线程同时执行的代码称作线程安全的代码
线程安全的代码不包含竞态条件
对象的安全
局部基本类型
局部变量存储在线程自己的栈中
局部变量永远不会被多个线程共享
所以基础类型的局部变量是线程安全的
局部的对象引用
对象的局部引用和基础类型的局部变量不太一样,尽管引用本身没有被共享,但引用所指的对象并没有存储在线程的栈内。所有的对象都存在共享堆中
如果在某个方法中创建的对象不会逃逸出(即该对象不会被其他方法获得,也不会被非局部变量引用到)该方法,那么它就是线程安全的
实际上,哪怕将这个对象作为参数传给其它方法,只要别的线程获取不到这个对象,那它仍是线程安全的。
对象成员(成员变量)
对象成员存储在堆上。如果两个线程同时更新同一个对象的同一个成员,那这个代码就不是线程安全的
不可变性
通过创建不可变的共享对象来保证对象在线程间共享时不会被修改,从而实现线程安全。
请注意ImmutableValue类的成员变量value是通过构造函数赋值的,并且在类中没有set方法。这意味着一旦ImmutableValue实力被创建,value变量就不能再被修改,这就是不可变性。但你可以通过getValue()方法读取这个变量的值。
Java内存模型
Java内存模型即Java Memory Model,简称JMM。JMM定义了Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式。JVM是整个计算机虚拟模型,所以JMM是隶属于JVM的。
线程之间的通信
线程的通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种共享内存和消息传递。
在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信,典型的共享内存通信方式就是通过共享对象进行通信。
在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来现实进行通信,在java中典型的消息传递方式就是wait()和notify()。
线程之间的同步
同步是指程序用于控制不同线程之间操作发生相对顺序的机制
在共享内存并发模型里,同步是显式进行的。程序员必须显示指定某个方法或某段代码需要在线程之间互斥执行
Java并发采用的是共享内存模型
Java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。如果编写多线程程序的Java程序员不理解隐式进行的线程之间通信的工作机制,很可能会遇到各种各样奇怪的内存可见性问题
Java内存模型结构
Java内存模型(简称JMM),JMM决定一个线程对共享变量的写入何时对另一个线程可见。
从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写/共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。
从上图来看,线程A与线程B之间如要通信的话,必须要经历下面的2个步骤:
1.首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去
2.然后,线程B到主内存中去读取线程A之前已更新过的共享变量
CAS乐观锁
乐观锁:不加锁,假设没有冲突去完成某项操作,如果因为冲突失败就重试,直到成功为止。其实现方式有一种比较典型的就是Compare and Swap(CAS)。
CAS机制当中使用了3个基本操作数:内存地址V,旧的预期值A,要修改的新值B。
更新一个变量的时候,只有当变量的预期值A和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B。
看一个例子:
1.在内存地址V中,存储着为10的变量
2.此时线程1想要把变量的值增加1。对线程1来说,旧的预期值A=10,要修改的新值B=11。
3.在线程1要提交更新之前,另一个线程2抢先一步,把内存地址V中对变量值率先更新成了11。
4.线程1开始提交更新,首先进行A和地址V的实际值比较(Compare),发现A不等于V的实际值,提交失败。
5.线程1重新获取内存V的当前值,并重新计算想要修改的新值。此时对线程1来说,A=11,B=12。这个尝试的过程被称为自旋。
6.这一次比较幸运,没有其他线程改变地址V的值。线程1进行Compare,发现A和地址V的实际值是相等的。
7.线程1进行SWAP,把地址V的值替换为B,也就是12。
从思想上来说,Synchronized属于悲观锁,悲观地认为程序中的并发情况严重,所以严防死守。CAS属于乐观锁,乐观地认为程序中的并发情况不那么严重,所以让线程不断去尝试更新。
CAS的缺点:
1.CPU开销较大
在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给CPU带来很大的压力。
2.不能保证代码块的原子性
CAS机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证3个变量共同进行原子性的更新,就不得不使用Synchronized了。
Synchronized块
Java中的同步块用synchronized标记。同步块在Java中是同步在某个对象上。所有同步在一个对象上的同步块在同时只能被一个线程进入并执行操作。所有其他等待进入该同步块的线程将被阻塞,直到执行该同步块中的线程退出。
有四种不同的同步块:
实例方法
静态方法
实例方法中的同步块
静态方法中的同步块
上述同步块都同步在不同对象上。实际需要哪种同步块视具体情况而定。
实例方法同步
下面是一个同步的实例方法
注意在方法声明中同步(synchronized)关键字
Java实例方法同步是在拥有该方法的对象上。这样,每个实例其方法同步都同步在不同的对象上,即该方法所属的实例。只有一个线程能够在实例方法同步块中运行。如果有多个实例存在,那么一个线程一次可以在一个实例同步块中执行操作。
静态方法同步
静态方法同步和实例方法同步方法一样,也适用synchronized关键字。Java静态方法同步如下示例:
同样,这里synchronized关键字告诉Java这个方法是同步的。
静态方法的同步是指同步在该方法所在的类对象上。因为在Java虚拟机中一个类只能对应一个类对象,所以同时只允许一个线程执行同一个类中的静态同步方法。
对于不同类的静态同步方法,一个线程可以执行每个类中的静态方法而无需等待。不管类中的那个静态同步方法被调用,一个类只能由一个线程同时执行。
有时候你不需要同步整个方法,而是同步方法中的一部分。Java可以对方法的一部分进行同步。
在非同步的Java方法中的同步块的例子如下所示:
示例使用Java同步块构造器来标记一块代码是同步的。该代码在执行时和同步方法一样。
注意Java同步块构造器用括号将对象括起来。在上例中,使用了“this”,即为调用add方法的实例本身。在同步构造器中用括号括起来的对象叫做监视器对象。上述代码使用监视器对象同步,同步实例方法使用调用方法本身的实例作为监视器对象。
一次只有一个线程能够在同步于同一个监视器对象的Java方法内执行。
下面两个例子都同步他们所调用的实例对象上,因此他们在同步的执行效果上是等效的。
在上例中,每次只有一个线程能够在两个同步块中任意一个方法内执行。
如果第二个同步块不是在this实例对象上,那么两个方法可以被线程同时执行。
和上面类似,下面是两个静态方法同步的例子。这些方法同步在该方法所属的类对象上。
这两个方法不允许同时被线程访问。
如果第二个同步块不是同步在MyClass.class这个对象上。那么这两个方法可以同时被线程访问。
Synchronized锁的存储
synchronized用的锁存储在Java对象头,如果对象是数组类型,则虚拟机用3个字宽存储对象头,如果对象是非数组类型,则用2个字宽存储对象头,32位虚拟机,1字宽等于4字节,即32位。
Java对象头的长度
Mark Word的存储结构
Mark Word可能的存储结果
偏向锁
偏向锁的获取流程:
(1)查看Mark Word中偏向锁的标识以及锁标志位,若是否偏向锁为1且锁标识位为01,则该锁为可偏向状态。
(2)若为可偏向状态,则测试Mark Word中的线程ID是否与当前线程相同,若相同,表示线程已经获得了锁,如果不同,则进入(3)
(3)测试Mark Word的偏向锁的标识是否设置为1,如果没有设置,则使用CAS操作竞争锁;如果设置了,则尝试使用CAS尝试将Mark Word中线程ID设置为当前线程ID,如果尝试失败,则执行(4)
(4)当前线程通过CAS竞争锁失败的情况下,说明有竞争。当到达全局安全点(在这个时间点,没有正在执行的代码)时之前获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。
轻量级锁
轻量级锁不是用来代替传统的重量级锁的,而是在没有多线程竞争的情况下,使用轻量级锁能够减少性能消耗,但是当多个线程同时竞争锁时,轻量级锁会膨胀为重量级锁。
轻量级锁的加锁过程:
(1)当线程执行代码进入同步块时,若Mark Word为无锁状态,虚拟机先在当前线程的栈帧中建立一个名为Lock Record的空间,用于存储当前对象的Mark Word的拷贝,官方称之为“Dispalced Mark Word”,此时状态如下图:
(2)复制对象头中的Mark Word到锁记录中。
(3)复制成功后,虚拟机将用CAS操作将对象的Mark Word更新为执行Lock Record的指针,并将Lock Record里的owner指针指向对象的Mark Word。如果更新成功,则执行4,否则执行5。
(4)如果更新成功,则这个线程拥有了这个锁,并将锁标志设为00,表示处于轻量级锁状态,此时状态图:
(5)如果更新失败,则说明有其它线程竞争锁,当前线程便通过自旋来获取锁。轻量级锁就会膨胀为重量级锁,Mark Word中存储重量级锁(互斥锁)的指针,后面等待锁的线程也要进入阻塞状态。
重量级锁
即当有其他线程占用锁时,当前线程会进入阻塞状态。
关键字Volatile
Volatile是轻量级的synchronized,在多线程处理器环境下,可以保证共享变量的可见性。它不会引起线程上下文的切换和调度,正确的使用Volatile,比synchronized的使用和执行成本更低。
可见性
可见性,是指线程之间的可见性,一个线程修改的状态对另一个线程是可见的。也就是一个线程修改一个共享变量时,另一个线程马上就能看到。比如:volatile修饰的变量,就会具有可见性。
volatile修饰的变量不允许线程内部缓存和重排序,即直接修改内存。所以对其它线程是可见的。但是这里需要注意一个问题,volatile只能被它修饰内容具有可见性,但不能保证它具有原子性。比如:volatile int a = 0;之后有一个操作 a++;这个变量a具有可见性,但是a++ 依然是一个非原子性操作,也就是这个操作同样存在线程安全问题。
在Java中volatile、synchronized 和 final 实现可见性。
原子性
原子是世界上最小的单位,具有不可分割性。比如: a=0;(a非long和double类型)这个操作是不可分割的,那么我们说这个操作是原子操作。再比如 a++;这个操作实际是 a = a + 1;是可分割的,所以它不是一个原子操作。非原子操作都会存在线程安全问题,需要我们使用同步技术(synchronized)来让它变成一个原子操作。一个操作是原子操作,那么我们称它具有原子性。在java的AtomicInteger、AtomicLong、AtomicReference等。
在Java中 synchronized 和 在lock、unlock 中操作保证原子性。
有序性
Java 语言提供了 volatile 和 synchronized 两个关键字来保证线程之间操作的有序性,volatile 是因为其本身包含“禁止指令重排序”的语义,synchronized是由“一个变量在同一个时刻只允许一条线程对其进行lock操作”这条规则获得的,此规则决定来持有同一个对象锁的两个同步块只能串行执行。
Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保变量的更新操作通知到其他线程。当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时,总会返回最新写入的值。
在访问volatile变量时不会执行加锁操作,因此也就不会使线程阻塞,因此volatile变量是一种比synchronized关键字更轻量级的同步机制。
当一个变量定义为volatile之后,将具备两种特效:
保证此变量对所有的线程的可见性,这里的“可见性”,如开头所说,当一个线程修改了这个变量当值,volatile保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。但普通变量做不到这点,普通变量的值在线程间传递均需要通过主内存来完成。
font color=\"#f15a23\
本地线程
Java中ThreadLocal类允许我们创建只能被同一个线程读写的变量,因此,如果一段代码含有一个ThreadLocal变量的引用,即使两个线程同时执行这段代码,它们也无法访问到对方的ThreadLocal变量。
如何创建ThreadLocal变量
以下代码展示了如何创建一个ThreadLocal变量
我们可以看到,通过这段代码实例化了一个ThreadLocal对象。我们只需要实例化对象一次,并且也不需要知道它是被那个线程实例化。虽然所有的线程都能访问到这个ThreadLocal实例,但是每个线程却只能访问到自己通过ThreadLocal的set()方法设置的值。即使是两个不同的线程在同一个ThreadLocal对象上设置了不同的值,他们仍然无法访问到对方的值。
如何访问ThreadLocal变量
一旦创建了一个ThreadLocal变量,你可以通过如下代码设置某个需要保存的值:
可以通过下面方法读取保存在ThreadLocal变量中的值:
ThreadLocal例子:
上面的例子一旦创建了一个MyRunnable实例,并将该实例作为参数传递给两个线程。两个线程分别执行run()方法,并且都在ThreadLocal实例上保存了不同的值。如果它们访问的不是ThreadLocal对象并且调用set()方法被同步了,则第二个线程会覆盖掉第一个线程设置的值。但是,由于它们访问的是一个ThreadLocal对象,因此这两个线程都无法看到对方保存的值。也就是说,它们存取的是两个不同的值。
关于InheritableThreadLocal
InheritableThreadLocal类是ThreadLocal类的子类。ThreadLocal中每个线程拥有它自己的值,与ThreadLocal不同的是,InheritableThreadLocal允许一个线程以及该线程创建的所有子线程都可以访问它保存的值。
多线程问题
死锁
死锁的产生
死锁是两个或更多线程阻塞着等待其它处于死锁状态的线程所持有的锁。死锁通常发生在多个线程同时但以不同的顺序请求同一组锁的时候。
例如,如果线程1锁住了A,然后尝试对B进行加锁,同时线程2以及锁住了B,接着尝试对A进行加锁,这时死锁就发生了。线程1永远得不到B,线程2也永远得不到A,并且它们永远也不会知道发生了这样对事情。为了得到彼此的对象(A和B),它们将永远阻塞下去。这种情况就是一个死锁。
该情况如下:
更复杂的死锁
死锁可能不只包含2个线程,这让检测死锁变得更加困难。下面是4个线程发生死锁的例子:
线程1等待线程2,线程2等待线程3,线程3等待线程4,线程4等待线程1。
数据库的死锁
更加复杂的死锁场景发生在数据库事务中。一个数据库事务可能由多条SQL更新请求组成。当在一个事务中更新一条记录,这条记录就会被锁住避免其他事务的更新请求,直到第一个事务结束。同一个事务中每一个更新请求都可能会锁住一些记录。
当多个事务同时需要对一些相同的记录做更新操作时,就很有可能发生死锁,例如:
因为锁发生在不同的请求中,并且对于一个事务来说不可能提前知道所有它需要的锁,因此很难检测和避免数据库事务中的死锁。
死锁的避免
加锁顺序
当多个线程需要相同的一些锁,但是按照不同的顺序加锁,死锁就很容易发生。
如果能够确保所有的线程都是按照相同的顺序获得锁,那么死锁就不会发生。
如果一个线程(比如线程3)需要一些锁,那么它必须按照确定的顺序获取锁。它只有获得了从顺序上排在前面的锁之后,才能获取后面的锁。
例如,线程2和线程3只有在获取了锁A之后才能尝试获取锁C(注:获取锁A是获取锁C的必要条件)。因为线程1已经拥有了锁A,所以线程2和3需要一直等到锁A被释放。然后在它们尝试对B或者C加锁之前,必须成功地对A加了锁。
按照顺序加锁是一种有效的死锁预防机制。但是,这种方式需要你事先知道所有可能会用到的锁(注:并对这些锁做适当的排序),但总有些时候是无法预知的。
加锁时限
另外一个可以避免死锁的方法是在尝试获取锁的时候加一个超时世界,这也就意味着在尝试获取锁的过程中若超过了这个时限则放弃对该锁对请求。若一个线程没有在给定的时限内成功获得所有的锁,则会进行回退并释放所有已经释放的锁,然后等待一段随机的时间再重试。这段随机的等待时间让其他线程有机会尝试获取相同的这些锁,并且让该应用在没有获得锁的时候可以继续允许(注:加锁超时后可以先继续运行干点其他事情,再回头来重复之前加锁的逻辑)。
以下锁一个例子,展示了两个线程以不同的顺序尝试获取相同的两个锁,在发生超时后回退并重试的场景:
在上面的例子中,线程2比线程1早200毫秒进行重试加锁,因此它可以先成功获取到两个锁。这是线程1尝试获取锁A并且处于等待状态。当线程2结束时,线程1也可以顺利获得这两个锁(除非线程2或者其他线程在线程1成功获得两个锁之前又获得其中的一些锁)。
需要注意的是,由于存在锁的超时,所以我们不能认为这种场景就一定是出现了死锁。也可能是因为获得了锁的线程(导致其它线程超时)需要很长的时间去完成它的任务。
此外,如果有非常多的线程同一时间去竞争同一批资源,就算有超时和回退机制,还是可能会导致这些线程重复地尝试但却始终得不到锁。如果只有两个线程,并且重试的超时时间设定为0到500毫秒之间,这种现象可能不会发生,但是如果10个或20个线程情况就不同了。因为这些线程等待相同的重试时间的概率就高得多(或者非常接近以至于会出现问题)。
死锁检测
死锁检测是一个更好的死锁预防机制,它主要针对那些不可能实现按序加锁并且锁超时也不可行的场景。每当一个线程获得了锁,会在线程和锁相关的数据结构中(map、graph等等)将其记下。除此之外,每当有线程请求锁,也需要记录在这个数据结构中。
当一个线程请求锁失败时,这个线程可以遍历锁的关系图看看是否有死锁发生。例如,线程A请求锁7,但是锁7这个时候被线程B持有,这时线程A就可以检查一下线程B是否已经请求了线程A当前所持有的锁。如果线程B确实有这样的请求,那么就是发生了死锁(线程A拥有锁1,请求锁7;线程B拥有锁7,请求锁1)。
当然死锁一般要比两个线程互相持有对方的锁这种情况要复杂得多。线程A等待线程B,线程B等待线程C,线程C等待线程D,线程D又在等待线程A。线程A为了检测死锁,他需要递进地检测所有被B请求的锁。从线程B锁请求的锁开始,线程A找到了线程C,然后又找到了线程D,发现线程D请求的锁被线程A自己持有着。这时他就知道发生了死锁。
那么当检测出死锁时,这些线程该做些什么呢?
一个可行的做法时释放所有锁,回退,并且等待一段随机的时间后重试。这个和简单的加锁超时类似,不一样的时只有死锁已经发生了才回退,而不会是因为加锁的请求超时了。虽然又回退和等待,但是如果有大量的线程竞争同一批锁,它们还是会重复地死锁(注:原因同超时类似,不能从根本上减轻竞争)。
一个更好的方案是给这些线程设置优先级,让一个(或几个)线程回退,剩下的线程就像没发生死锁一样继续保持着它们需要的锁。如果赋予这些线程的优先级是固定不变的,同一批线程总是会拥有更高的优先级。为了避免这个问题,可以在死锁发生的时候设置随机的优先级。
饥饿和公平
如果一个线程因为CPU时间全部被其他线程抢走而得不到CPU运行时间,这种状态被称之为“公平性” -即所有线程均能公平地获得运行机会。
在Java中导致饥饿地原因
在Java中,下面三个常见的原因会导致线程饥饿:
高优先级线程吞噬所有低优先级线程的CPU时间
你能为每个线程设置独自的线程优先级,优先级越高的线程获得的CPU时间越多,线程优先级设置在1到10之间,而这些优先级值锁表示行为的准确解释则依赖于你的应用运行平台。对大多数应用来说,你最好是不要改变其优先级值。
线程永远堵塞在一个等待进入同步块的状态
Java的同步代码区也是一个导致饥饿的因素。Java的同步代码区对哪个线程允许进入的次序没有任何保障。这就意味着理论上存在一个试图进入该同步区的线程处于被永久堵塞的风险,因为其他线程总能持续地先于它获得访问,这就是“饥饿”问题,而一个线程被“饥饿致死”正是因为它得不到CPU运行时间的机会。
线程在等待一个本身(在其上调用wait())也处于永久等待完成的对象
如果多个线程处于wait()方法执行上,而对其调用notify()不会保证哪一个线程会获得唤醒,任何线程都有可能处于继续等待的状态。因此存在这样一个风险:一个等待线程从来得不到唤醒,因为其他等待线程总是能被获得唤醒。
0 条评论
回复 删除
下一页