总结了线程安全性的二十四个精华问题

举报
兔老大 发表于 2021/04/25 23:44:21 2021/04/25
【摘要】   1、对象的状态:对象的状态是指存储在状态变量中的数据,对象的状态可能包括其他依赖对象的域。在对象的状态中包含了任何可能影响其外部可见行为的数据。   2、一个对象是否是线程安全的,取决于它是否被多个线程访问。这指的是在程序中访问对象的方式,而不是对象要实现的功能。当多个线程访问某个状态变量并且其中有一个线程执行写入操作时,必须采用同步机制来协同这...

 

1、对象的状态:对象的状态是指存储在状态变量中的数据,对象的状态可能包括其他依赖对象的域。在对象的状态中包含了任何可能影响其外部可见行为的数据。

 

2、一个对象是否是线程安全的,取决于它是否被多个线程访问。这指的是在程序中访问对象的方式,而不是对象要实现的功能。当多个线程访问某个状态变量并且其中有一个线程执行写入操作时,必须采用同步机制来协同这些线程对变量的访问。同步机制包括synchronized、volatile变量、显式锁、原子变量。

 

3、有三种方式可以修复线程安全问题:

1)不在线程之间共享该状态变量

2)将状态变量修改为不可变的变量

3)在访问状态变量时使用同步

 

4、线程安全性的定义:当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步,这个类都能表现出正确的行为,那么就称这个类是线程安全的。

 

5、无状态变量一定是线程安全的,比如局部变量。

 

6、读取-修改-写入操作序列,如果是后续操作是依赖于之前读取的值,那么这个序列必须是串行执行的。在并发编程中,由于不恰当的执行时序而出现不正确的结果是一种非常重要的情况,它称为竞态条件(Race Condition)。最常见的竞态条件类型就是先检查后执行的操作,通过一个可能失效的观测结果来决定下一步的操作。


7、复合操作:要避免竞态条件问题,就必须在某个线程修改该变量时,通过某种方式防止其他线程使用这个变量,从而确保其他线程只能在修改操作完成之前或之后读取和修改状态,而不是在修改状态的过程中。假定有两个操作A和B,如果从执行A的线程看,当另一个线程执行B时,要么将B全部执行完,要么完全不执行B,那么A和B对彼此来说就是原子的。原子操作是指,对于访问同一个状态的所有操作来说,这个操作是一个以原子方式执行的操作。

为了确保线程安全性,读取-修改-写入序列必须是原子的,将其称为复合操作。复合操作包含了一组必须以原子方式执行的接口以确保线程安全性。

 

8、在无状态的类中添加一个状态时,如果这个状态完全由线程安全的对象来管理,那么这个类仍然是线程安全的。(比如原子变量)

 

9、如果多个状态是相关的,需要同时被修改,那么对多个状态的操作必须是串行的,需要进行同步。要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。

 

10、内置锁:synchronized(object){同步块}

Java的内置锁相当于一种互斥体,这意味着最多只有一个线程能持有这种锁,当线程A尝试获取一个由线程B持有的锁时,线程A必须等待或阻塞,直到线程B释放这个锁。如果B永远不释放锁,那么A也将永远地等待下去。

 

11、重入:当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而,由于内置锁是可重入的,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。重入意味着获取锁的操作的粒度是线程,而不是调用。重入的一种实现方法是,为每个锁关联一个获取计数值和一个所有者线程。当计数值为0时,这个锁就被认为是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数值置1。如果一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数值会相应递减。当计数值为0时,这个锁将被释放。

 

12、对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁,在这种情况下,我们称状态变量是由这个锁保护的。

每个共享的和可变的变量都应该只由一个锁来保护,从而使维护人员知道是哪一个锁。

一种常见的加锁约定是,将所有的可变状态都封装在对象内部,并提供对象的内置锁(this)对所有访问可变状态的代码路径进行同步。在这种情况下,对象状态中的所有变量都由对象的内置锁保护起来。

 

13、不良并发:要保证同步代码块不能过小,并且不要将本应是原子的操作拆分到多个同步代码块中。应该尽量将不影响共享状态且执行时间较长的操作从同步代码块中分离出去,从而在这些操作的执行过程中,其他线程可以访问共享状态。

 

14、可见性:为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制。

 

15、加锁与可见性:当线程B执行由锁保护的同步代码块时,可以看到线程A之前在同一个同步代码块中的所有操作结果。如果没有同步,那么就无法实现上述保证。加锁的含义不仅仅局限于互斥行为,还包括内存可见性。为了确保所有线程都能看到共享变量的最新值,所有执行读操作或写操作的线程都必须在同一个锁上同步。

 

16、volatile变量:当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或其他对处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。volatile的语义不足以确保递增操作的原子性,除非你能确保只有一个线程对变量执行写操作。原子变量提供了“读-改-写”的原子操作,并且常常用做一种更好的volatile变量。

 

17、加锁机制既可以确保可见性,又可以确保原子性,而volatile变量只能确保可见性。

 

18、当且仅当满足以下的所有条件时,才应该使用volatile变量:

1)对变量的写入操作不依赖变量的当前值(不存在读取-判断-写入序列),或者你能确保只有单个线程更新变量的值

2)该变量不会与其他状态变量一起纳入不可变条件中

3)在访问变量时不需要加锁

 

19、栈封闭:在栈封闭中,只能通过局部变量才能访问对象。维护线程封闭性的一种更规范的方法是使用ThreadLocal,这个类能使线程的某个值与保存值的对象关联起来,ThreadLocal通过了get和set等访问接口或方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此get总是返回由当前执行线程在调用set时设置的最新值。

 

20、在并发程序中使用和共享对象时,可以使用一些使用的策略,包括:

1)线程封闭:线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改。

2)只读共享:在没有额外同步的情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它。共享的只读对象包括不可变对象和事实不可变对象(从技术上来说是可变的,但其状态在发布之后不会再改变)。

3)线程安全共享。线程安全的对象在其内部实现同步,因此多个线程可以通过对象的公有接口来进行访问而不需要进一步的同步。

4)保护对象。被保护的对象只能通过持有对象的锁来访问。保护对象包括封装在其他线程安全对象中的对象,以及已发布并且由某个特定锁保护的对象。

 

21、饥饿:当线程由于无法访问它所需要的资源而不能继续执行时,就发生了饥饿(某线程永远等待)。引发饥饿的最常见资源就是CPU时钟周期。比如线程的优先级问题。在Thread API中定义的线程优先级只是作为线程调度的参考。在Thread API中定义了10个优先级,JVM根据需要将它们映射到操作系统的调度优先级。这种映射是与特定平台相关的,因此在某个操作系统中两个不同的Java优先级可能被映射到同一优先级,而在另一个操作系统中则可能被映射到另一个不同的优先级。

当提高某个线程的优先级时,可能不会起到任何作用,或者也可能使得某个线程的调度优先级高于其他线程,从而导致饥饿。

通常,我们尽量不要改变线程的优先级,只要改变了线程的优先级,程序的行为就将与平台相关,并且会导致发生饥饿问题的风险。

 

事务T1封锁了数据R,事务T2又请求封锁R,于是T2等待。T3也请求封锁R,当T1释放了R上的封锁后,系统首先批准了T3的请求,T2仍然等待。然后T4又请求封锁R,当T3释放了R上的封锁之后,系统又批准了T的请求......T2可能永远等待

 

22、活锁

活锁是另一种形式的活跃性问题,该问题尽管不会阻塞线程,但也不能继续执行,因为线程将不断重复执行相同的操作,而且总会失败。活锁通常发生在处理事务消息的应用程序中。如果不能成功处理某个消息,那么消息处理机制将回滚整个事务,并将它重新放到队列的开头。虽然处理消息的线程并没有阻塞,但也无法继续执行下去。这种形式的活锁通常是由过度的错误恢复代码造成的,因为它错误地将不可修复的错误作为可修复的错误。

 

当多个相互协作的线程都对彼此进行响从而修改各自的状态,并使得任何一个线程都无法继续执行时,就发生了活锁。要解决这种活锁问题,需要在重试机制中引入随机性。在并发应用程序中,通过等待随机长度的时间和回退可以有效地避免活锁的发生。

 

23、当在锁上发生竞争时,竞争失败的线程肯定会阻塞。JVM在实现阻塞行为时,可以采用自旋等待(Spin-Waiting,指通过循环不断地尝试获取锁,直到成功),或者通过操作系统挂起被阻塞的线程。这两种方式的效率高低,取决于上下文切换的开销以及在成功获取锁之前需要等待的时间。如果等待时间较短,则适合采用自旋等待的方式,而如果等待时间较长,则适合采用线程挂起方式。

 

24、有两个因素将影响在锁上发生竞争的可能性:锁的请求频率,以及每次持有该锁的时间。如果二者的乘积很小,那么大多数获取锁的操作都不会发生竞争,会因此在该锁上的竞争不会对可伸缩性造成严重影响。然而,如果在锁上的请求量很高,那么需要获取该锁的线程将被阻塞并等待。在极端情况下,即使仍有大量工作等待完成,处理器也会被闲置。

有3种方式可以降低锁的竞争程度:

1)减少锁的持有时间:

①缩小锁的范围,将与锁无关的代码移出同步代码块,尤其是开销较大的操作以及可能被阻塞的操作(IO操作)。

当把一个同步代码块分解为多个同步代码块时,反而会对性能提升产生负面影响。在分解同步代码块时,理想的平衡点将与平台相关,但在实际情况中,仅可以将一些大量的计算或阻塞操作从同步代码块移出时,才应该考虑同步代码块的大小。

②减小锁的粒度:锁分解和锁分段

锁分解是采用多个相互独立的锁来保护独立的状态变量,从而改变这些变量在之前由单个锁来保护的情况。这些技术能减小锁操作的粒度,并能实现更高的可伸缩性,然而,使用的锁越多,那么发生死锁的风险也就越高。

锁分段:比如JDK1.7及之前的ConcurrentHashMap采用的方式就是分段锁的方式。

2)降低锁的请求频率

3)使用带有协调机制的独占锁,这些机制允许更高的并发性

比如读写锁,并发容器等

 

文章来源: fantianzuo.blog.csdn.net,作者:兔老大RabbitMQ,版权归原作者所有,如需转载,请联系作者。

原文链接:fantianzuo.blog.csdn.net/article/details/103410734

【版权声明】本文为华为云社区用户转载文章,如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。