Java 17:Java并发编程实战1

2、线程安全性

线程允许同一个进程中同时存在多个程序控制流,同一个程序中的多个进程可以同时调度到多个CPU上运行

线程会共享资源例如:内存句柄、文件句柄,内存地址空间,都能访问相同的变量并在同一个堆上分配对象

线程独有:程序计数器、栈,局部变量


安全性:永远不发生糟糕的事情

活跃性:某个正确的事最终会发生


线程安全的程序,核心在于对于“共享”的、“可变”的状态的访问。

当多个线程访问状态变量并且其中有一个线程执行写操作时,必须采用同步机制

解决同步问题的3个方法:1、不在线程之间共享状态变量(线程封闭)2、将状态变量改为不可变的对象(不可变对象总是线程安全的)3、同步策略


程序的封装性越好,越容易实现程序的安全性


线程安全性:当多个线程调用某个类时,不管采用何种调度方式,在主调程序代码中不需要加入任何额外的同步,都可以正确运行。

线程安全类封装了必要的同步机制,因此客户端无需进一步采取同步机制

对于对象来说,操作是调用对象的共有方法,或者直接读写其公有域,在线程安全类的对象实例上执行的任何串行或者并行操作都不会使对象出于无效状态


无状态的对象一定是线程安全的(不包含任何域,也不包含对于其他类中域的引用),计算过程中的临时状态仅存在于线程栈上的局部变量,并且只能由正在执行的线程访问

大多数Servlet都是无状态的,极大程度上降低了实现Servlet线程安全性时的复杂性。


竞态条件(Race Condition)由于不恰当的执行顺序出现不正确的结果,也就是计算的正确性取决于多个线程的交替执行顺序


出现竞态条件:

++count:看上去是一个紧凑的语法,其实执行按照“读取-修改-写入”的顺序执行,并非一个不可分割的原子操作,(先检查、后执行)

延迟初始化:单例模式的例子,先判断对象是否已经初始化,存在则返回实例,否则新建实例。可能出现同时有两个线程判断instance为空,然后各自初始化得到一个实例。


出现上述竞态条件是因为操作不是不是原子的,可以使用现有的线程安全类,Java.util.concurrent.atomic包含了一些原子变量类,比如:AtomicLong,可以保证对这些变量的操作都是原子的。

当在无状态的类中添加一个状态时,如果该状态完全由线程安全的对象来管理,那么这个类仍然是线程安全的。但是当用到了多个变量时,仅仅将各个变量各自实现为线程安全的,还是会发生错误。因为,变量间可能存在联系合约束,当更新一个变量时,需要在同一个原子操作中对其他相关变量同时进行更新。


内置锁(同步代码块):使用Synchronized关键词来修饰,被修饰的方法。每一个Java对象都可以用作一个实现了同步的锁,这些锁称为内置锁或监视器锁,进入同步代码块之前会自动获取锁,并且在退出同步代码块时自动释放锁(无论是通过正常的路径退出,还是通过代码块中抛出异常退出)一次只能有一个线程能获得内置锁,

内置锁可以重入,可以重新申请进入自己已经拥有的锁代码,有一个计数器值用来表示获取锁的次数,每释放一次计数也会相应地递减,直到变为0,锁将被释放,重入主要是可以解决集成代码的问题,子程序和父程序各自加锁,不能重入则会锁死


当使用锁来协调对某个变量的访问时,在访问变量的所有位置上都要使用同一个锁。

内置锁的额外作用只是免去显式地创建锁。

内置锁的作用只是阻止其他线程获取同一个锁,与状态本身没有内在的关联,当获取与对象关联的锁时也并不能阻止其他线程访问该对象(只能阻止访问受锁保护的代码)


最常用的加锁约定是,将所有可变对象都封装在对象内部(不是public域,不能被外部直接修改,而要通过public方法),并通过对象的内置锁对所有可访问状态进行保护,使得在该对象上不会发生并发访问,很多线程安全类都采用这种模式,例如Vector。

虽然Vector的必要操作都是Synchronized的,但是这并不能保证Vector上的所有复合操作也是原子的。

if(!vector.contains(element))
    vector.add(element);
奇怪的是contains方法并没有加锁,即使加了锁,也不能保证两个原子操作(先检查,后操作)之间不会有其余线程改变了检查结果,

vector的线程安全只能保证对于单个方法,无法并发访问,两个有明显关联的方法产生的问题,还是得靠调用代码处自己加锁同步。

姑且认为vector本身是线程安全的吧(对于单一原子操作来说,加了很多Synchronized)

将每个方法都设置为Sychronized方法,只能保证单个操作的原子性,如果还有复合操作,则需要额外的加锁机制,过多的Synchronized反而会造成活跃性问题、性能问题

P25的同步代码很精髓


3、对象的共享

关键词Synchronized不止能用于实现原子性或者临界区,同步还有另一个重要的方面:内存可见性。希望确保一个线程修改了对象状态以后,其他线程能够看到状态变化,如果没有同步就难以保证。


单线程中,向某个变量写入值,然后在没有其他写入操作的情况下读取值,那么总能得到相同的值,然而如果读写操作在不同线程中进行,并不能保证能及时看到其他线程写入的值,为了能能够保证多个线程之间对内存的写入是可见的,也必须使用同步

看不见,或者由于重排序遗漏了部分信息,除非每次访问变量时都使用同步,否则很可能获取该变量的一个失效数据,有可能一个线程获得了最新的数据,而另一个则得到的是失效的数据。

在没有同步的情况下可能会获得“失效值”,但至少这个值是之前设置过的而不是随机的,这种安全保证称为最低安全性。但是非原子的64操作可能产生问题,Java中对非volatile的long和double变量,允许将64位的读写操作分解为两个32位的,这时候可能导致出现不同值的高低位,产生一个不存在的数据。


内置锁可以确保某一个线程以一种可以预测的方式来查看另一个线程的执行结果。当A执行某个同步代码块时,线程B随后进入同一个同步快,这能保证在锁释放前,A看到的变量在B获得锁后同样可以由B看到。也就是当B执行同步代码块时,可以看到线程A之前在同一同步快中进行的所有操作(这里还没有用到volatile变量),如果没有同步,则不能保证。

因此加锁的作用:互斥(原子性)+共享(可见性)


volatile是一种更轻量级的同步机制,用来确保将变量的更新操作,通知到其他线程。编译器会注意到volatile变量,不会对其进行重排序。也不会将其缓存到寄存器或者其他处理器不可见的地方,因此读取volatile变量总是返回最新写入的值。volatile不会使线程阻塞。volatile变量经常用作某个操作完成、中断的状态标志,虽然volatile也可以用来表示其他状态信息,但是要非常小心,例如volatile并没有加锁,也不能保证couter++操作的原子性。

volatile作用:仅为共享(可见性)

使用volatile的条件:1、不需要加锁2、没有和其他变量一起纳入不变性条件中3、对变量的写入不依赖当前值,或者只在单线程中更新


发布:使得对象能够在当前作用域之外的代码中被使用,当某个不应该被发布的对象被发布时,称为"逸出",在对象构造完成之前就发布该对象,则会破坏封装性。

当对象逸出以后,你必须假设某个类或线程可能误用该对象,不可以在构造函数中使得this引用逸出,是不正确构造。

在构造函数中创建线程,会使得对象在构造完全之前,就被新的线程看见,因此这种情况下,只能创建新线程,但不要立即启动它。


线程封闭:

如果数据只在单线程中访问,就不需要同步,称为“线程封闭”,当某个对象封闭在单个线程中,将自动实现线程安全性,即使该线程不是线程安全的。

当确定只有单个线程可以对数据进行写操作,将其声明为volatile,就可以安全地在变量上执行“读取-修改-写入”操作,修改操作封闭在单线程中避免了竞态条件,而volatile保证了修改对其他共享线程可见。

栈封闭:只能在局部变量中才能访问对象,局部变量的固有属性就是封闭在单个执行线程中

ThreadLocal:这个类使得线程中的某个值和保存值的对象关联起来,ThreadLocal提供的set、get方法为每个使用该变量的线程存有一份副本  ,感觉和局部变量差不多


不变性:

如果某个对象在被创建后其状态就不会被修改,则称为不可变对象,不可变的对象一定是线程安全的。

不可变不等于将所有的域声明为final,即使如此,也会因为final域保存的是可变对象的引用而导致对象是可变的

不可变要求:1、正确创建、构造期间,this没有逸出 2、所有域是final 3、创建以后就不能修改

不可变对象内部可以用可变对象来管理对象状态,只要不提供任何修改其内部状态的接口。保存在不可变对象中的状态依然可以更新,通过用一个保存新状态的实例替换原有的不可变对象。

当需要对一组相关的数据进行某个操作时,可以考虑创建一个不可变的类来包含这些数据(要变的时候就重新new,整个替换),对于一个不可变对象,程序获取该对象的引用后,就不必担心另一个线程修改 对象的状态,如果需要更新这些 变量,就创建一个新的容器对象,但其他所有使用原有对象的线程都会看到对象处于一致的状态。(final域能确保初始化过程的安全性)

使用不可变对象,差不多也就是1、多线程访问时无法修改其状态,不会有问题 2、要修改时整个替换,final域保证所有的状态都更新完了才发布(构造函数将修改多个状态的复合操作合成一个原子操作),发布完以后立刻被所有线程看到(可以声明为volatile变量),这样就不会产生失效状态了,这样在没有加锁的情况下实现同步。


4、对象的组合

构建线程安全的类,除了需要找出所有构成对象状态的变量,还有约束状态变量的不变性条件,并根据这些建立并发访问的管理策略。变量的值有范围限制,或者有要根据先前的值修改,这些需要额外的同步和封装,避免产生无效的状态。无效状态只能存在于某个原子操作的内部。


实例封闭:可以将非线程安全的类封装成线程安全的

当一个对象被封装在另一个对象中时,能够访问被封装对象的所有代码路径都是已知的,只有它的外部对象,相比对象被整个程序访问,更容易进行分析,通过封闭和加锁结合,可以以线程安全的方式,使用非线程安全的对象。实例封闭式构造线程安全性的最简单方式,只需要封闭+每次使用对象时加锁,就可以满足安全性。但是如果这个成员返回了不安全的对象,则需要额外的同步。

比如ArrayList、HashMap非线程安全,通过包装器方法,将对它们的使用封闭在包装器之内,则包装器能将接口实现为同步,并所有的请求再转发到底层的容器对象上。封闭的关键在于只需要考虑外面一层的调用代码。


委托:是创建线程安全类的最有效的策略,外部类的线程安全性,委托给内部类,比如只有一个域的类,只要这个域是线程安全的,外部类也是线程安全的。如果多个状态变量是彼此独立的,则外部类的安全性可以委托给这几个状态变量。

大部分情况下,状态变量之间存在不变形条件,这样单纯地组合线程安全状态,并不能得到线程安全类,还需要额外的加锁机制。

如果一个状态变量是线程安全的,并且没有任何不变性条件来约束它的值,在变量操作上也不存在不允许的状态改变,那么就可以安全地发布。如果存在某些取值限制,那就不能轻易将对象发布出去。


5、构建基础模块

同步容器类(都是线程安全的):Vector、Hashtable,它们实现线程安全的方法是:将状态封装起来,对每个共有方法都进行同步(加锁),保证一次只有一个线程访问容器的状态。虽然同步容器类是线程安全的,但是一些复合操作下依然需要额外的客户端加锁保护。比如先检查、后操作,会用到两个同步容器类的方法,先获取容器长度再删除、迭代都会产生问题。

对容器类迭代的标准方式是使用Iterator,然而设计同步容器类的迭代器时并没有考虑并发修改的问题,它们选择在迭代器件如果发现计数器被修改则报ConcurrentModificationException。如果想避免,就需要在迭代时持有锁,有的时候不希望在迭代期间加锁,可以选择克隆容器,并在副本上进行迭代,由于副本封闭在线程内,因此不会受其他线程影响,避免了抛出上述错误,当然克隆也存在明显的开销问题。

可以在同步容器的符合操作时,提前获得同步容器的锁。


并发容器:同步容器将所有对容器状态的访问都串行化,以实现线程安全性。但是这样严重降低了并发性。并发容器是针对多个线程并发访问设计的,比如ConcurrentHashMap替代Map,以及CopyOnWriteArrayList替代同步的List,ConcurrentMap接口中增加了一些对常用复合操作的支持,通过并发容器来代替同步容器,可以极大提高伸缩性并降低风险。


ConcurrentHashMap不是只在同一个锁上同步使得每次只有一个线程访问容器,而是使用粒度更细的加锁来实现最大程度的共享,称为“分段锁”,任意数量的读线程可以并发访问Map,并且读取和写入也可以并发进行,一定数量的写入线程可以并发修改Map,并且同步容器类不会抛出ConcurrentModeifIcationException,因此不需要再迭代期间加锁,ConcurrentHashMap具有弱一致性,可以容忍并发修改,可能导致size(),isEmpty()的失效,但这些方法本身再并发环境中作用很小,因为返回值总是不停变化。由于ConcurrentHashMap不能被加锁来进行独占访问,因此无法通过客户端加锁来实现新的原子操作。但很多诸如“若没有则添加、若相等则移除”的操作都已经实现为原子操作并置于ConcurrentMap接口中


CopyOnWriteArrayList:用于替代同步的List,在某些情况下提供了更好的并发性能,并在迭代器件不需要对容器进行加锁或复制。Copy on write写入时复制的线程安全性在于,只要发布一个事实不可变的对象,那么在访问该对象时就不要额外的同步,每次创建都会new 并发布新的容器复本,实现其可变性。因为每次修改都需要复制底层数组,需要一定的开销,特别是容器规模大时,仅当迭代操作远远多于修改操作时,才应该使用“写入时复制”容器



  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值