Thread专题(1) - 线程安全

时间:2022-10-11 09:58:32

此文收录被笔者收录在系列文章 ​​​架构师必备(系列)​​ 中

线程安全就是对共享的、可变的状态进行管理,对象的状态就是它的数据,换句话说就是在不可控制的并发访问中保护数据。一个对象是否应该是线程安全的取决于它是否会被多个线程访问。这个特性表明,不是对象完成了什么而是程序如何使用对象。

无论何时,只要多于一个的线程访问给定的状态变量,并且其中某个线程会写入该变量,此时必须使用同步来协调多个线程对该变量的访问。java中首要的同步机制是synchronized关键字,它提供了独占锁,另外还包括volatile变量、显示锁和原子变量等方式。设计程序时,一开始把程序设计成线程安全的比后期再进行维护要方便的多,因为封装、不可变性、明确的不变约束等设计会对程序提供很大的便利性。

完全由线程安全类组成的程序未必是线程安全的。线程安全程序也未必全部由线程安全类组成。如果不涉及I/O或访问共享数据,Ncpu或Ncpu+1个线程会产生最优吞吐量,更多的线程也不会有更多的帮助,还可能降低性能。

一、安全性

这是一个servlet的例子,

/*servlet是线程安全的,但前提条件是servlet是个无状态对象,也就是它不保存任何记录信息,如果在此处声明一个其它的变量,
这时就需要人为的进行线程安全控制*/
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);//自定义的方法
BigInteger[] factors = factor(i);//自定义的方法
encodeIntoResponse(resp, factors);//自定义的方法
}

二、原子性

下面例子中servlet不是线程安全的,主要原因是 ++ 操作并不是一个原子操作,它是由读-改-写三步完成的,此例子的不正确性是多方面的。这就涉及到竞争条件问题,比如单例模式的设计中如果多个线程同时操作时在偶然情况下可能发生返回多个实例,进而导致使用潜在的过期观察值来作决策或指导运算。

    private long count = 0;
public long getCount() { return count; }
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
++count;
encodeIntoResponse(resp, factors);
}
/*由于这个例子中只有一个复合操作,所以最简单的改正方式是把long变成AtomicLong原子类。
java.util.concurrent.atomic包中包括了原子变量。如果是多个变量还要同步多个变量间的交互过程。仅仅用原子变量是行不通的。*/
private final AtomicLong count = new AtomicLong(0);
public long getCount() { return count.get(); }
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
count.incrementAndGet();
encodeIntoResponse(resp, factors);
}

三、锁

在java中,1、按通用的维度可分为:悲观、乐观;2、按共享分为:独占和非独占(读写锁);3、按排它性可分为阻塞和非阻塞。在这里不会讲魔神所有的锁,详细也可以参考   ​​jvm专题(4) - 【3/3】多线程-锁​​ 文章中对锁的描述。

乐观锁

乐观锁是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作。

java 中的乐观锁基本都是通过 CAS 操作实现的,CAS 是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,否则失败。CAS的原理就是比较并交换+乐观锁+自旋这个原理设计;

悲观锁

悲观锁是就是悲观思想,即认为读少写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会 block 直到拿到锁。

java 中的悲观锁就是 Synchronized,AQS 框架下的锁则是先尝试 cas 乐观锁去获取锁,获取不到才会转换为悲观锁,如 RetreenLock。

自旋锁

自旋锁原理非常简单,如果持有锁的线程能在很短时间内能释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。线程自旋是需要消耗 cpu 的,说白了就是让 cpu 在做无用功,如果一直获取不到锁,那线程也不能一直占用 cpu 自旋做无用功,所以需要设定一个自旋等待的最大时间。

如果持有锁的线程执行的时间超过自旋等待的最大时间扔没有释放锁,就会导致其它争用锁的线程在最大等待时间内还是获取不到锁,这时争用线程会停止自旋进入阻塞状态。在1.6中有参数控制 -XX:+UseSpinning 和-XX:PreBlockSpin=10 为自旋次数控制,到了1.7由JVM控制。

在java.util.concurrent.atomic中的原子类基本是这种排他锁实现。是一种非阻塞算法。

优点:

自旋锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起再唤醒的操作的消耗的两次上下文切换!

但是如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,这时候就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用 cpu 做无用功,占着 XX 不 XX,同时有大量线程在竞争一个锁,会导致获取锁的时间很长,线程自旋的消耗大于线程阻塞挂起操作的消耗,其它需要 cup 的线程又不能获取到 cpu,造成 cpu 的浪费。所以这种情况下我们要关闭自旋锁;

    //原子对象的引用,用原子的方式更新对象的引用
private final AtomicReference<BigInteger> lastNumber = new AtomicReference<BigInteger>();
private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<BigInteger[]>();

//但下面的else的几步操作是一个复合操作,并不是一个原子操作,所以此类不是线程安全类
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
if (i.equals(lastNumber.get()))
encodeIntoResponse(resp, lastFactors.get());
else {
BigInteger[] factors = factor(i);
lastNumber.set(i);
lastFactors.set(factors);
encodeIntoResponse(resp, factors);
}
}

内部锁

synchronized是java提供的内置锁机制来保证原子性。它一般有两部分组成:锁对象的引用,以及这个锁保护的代码块synchronized(lock),它是基于每调用的

每个java对象都可以用作一个实现同步的锁,这些内置的锁被称作内部锁或监视器锁(锁对象)。执行线程进入synchronized块之前会自动获得锁,而在正常退出或异常退出时自动释放锁,获得内部锁的唯一途径是:进入这个内部锁保护的同步块或方法。内部锁是互斥锁。会严重影响程序性能,下面的例子可以用这种方式实现但不建议。另一方面,锁不仅仅只关于同步与互斥的,也是关于内存可见的,当访问一个共享的可变变量时,要求所有线程由同一个锁进行同步。

    @GuardedBy("this") private BigInteger lastNumber;
@GuardedBy("this") private BigInteger[] lastFactors;

public synchronized void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
if (i.equals(lastNumber))
encodeIntoResponse(resp, lastFactors);
else {
BigInteger[] factors = factor(i);
lastNumber = i;
lastFactors = factors;
encodeIntoResponse(resp, factors);
}
}

Synchronized作用范围

  • 作用于方法时:锁住的是对象的实例(this);
  • 当作用于静态方法时:锁住的是 Class 实例,又因为 Class 的相关数据存储在永久带 PermGen(jdk1.8 则是 metaspace),永久带是全局共享的,因此静态方法锁相当于类的一个全局锁,会锁所有调用该方法的线程;
  • synchronized 作用于一个对象实例时:锁住的是所有以该对象为锁的代码块。它有多个队列,当多个线程一起访问某个对象监视器的时候,对象监视器会将这些线程存储在不同的容器中。

重进入

当一个线程请求其他线程已占有的锁时会阻塞,而内部锁是可以重进入的。因为线程在试图获得它自己占有的锁时,请求会成功。这种实现方式是JVM通过为每个锁关联一个请求计数器和一个占有它的线程,同一个线程多次请求这个计数器会累加。直到计数器为0。锁被释放。

可重入锁是基于每线程。ReentantLock 继承接口 Lock 并实现了接口中定义的方法,他是一种可重入锁,除了能完成 synchronized 所能完成的所有工作外,还提供了诸如可响应中断锁、可轮询锁请求、定时锁等避免多线程死锁的方法。

//因为同一线程是可以重进入的,下面这种方式是基于每线程的,不是基于每调用的,但这只是针对继承这种方式而言。
public class Widget {
public synchronized void doSomeThing(){ }
}
class SubWidget extends Widget{
public synchronized void doSomeThing(){
super.doSomeThing();
}
}

用锁来保护状态

锁使得线程串行化运行,所以可以用锁来创建相关的协议。操作共享状态的复合操作必须是原子的,以避免竞争条件。对象的内部锁与它的状态之间没有内在的关系。获得了对象的锁后,唯一可以做的就是阻止其它线程再获得相同的锁。每个对象都有一个内部锁,所以你不需要显式地创建锁对象。

每个共享的可变变量都需要由唯一一个确定的锁保护,而维护者应该清楚这个锁一般的时候不要把synchronized拆的太散了。对于每一个涉及多个变更的不变约束,需要同一个锁保护其所有的变量。

另一种比较笨的方法是用对象的内部锁封装所有的可变状态, java中的很多线程安全类就是用这种方法实现的,比如Vector,如果想扩展时必须要注意一下线程安全的问题。如果在添加新方法时也需要注意这种锁协议,一旦未遵守就会变为非线程安全的类。

通常简单性与性能之间是相互牵制的,实现一个同步策略时,不要过早地为了性能而牺牲简单性(这是对安全性潜在的妥协),有些耗时的计算或操作,比如网络或控制台I/O,难以快速完成,执行这些操作其实不要占用锁。

锁优化

下面这些方法需要根据具体场景而采用相应的对策,不能不加思索而且盘采用,另外多线程的优化是比较繁琐的事,需要多次测试和比对,才能确定最终的文案。一个好的实践是先无脑从大处下手,同时采用几种方式做对比有了一定的数据后再缩小范围。

  • 减少锁持有时间:只用在有线程安全要求的程序上加锁
  • 减小锁粒度: 将大对象(这个对象可能会被很多线程访问),拆成小对象,大大增加并行度,降低锁竞争。 降低了锁的竞争,偏向锁,轻量级锁成功率才会提高。最最典型的减小锁粒度的案例就是ConcurrentHashMap。
  • 锁分离:最常见的锁分离就是读写锁 ReadWriteLock,读写分离思想可以延伸,只要操作互不影响,锁就可以分离。比如LinkedBlockingQueue 从头部取出,从尾部放数据
  • 锁粗化:凡事都有一个度,如果对同一个锁不停的进行请求、同步和释放,其本身也会消耗系统宝贵的资源,反而不利于性能的优化
  • 锁消除: 锁消除是在编译器级别的事情。在即时编译器时,如果发现不可能被共享的对象,则可以消除这 些对象的锁操作,多数是因为程序员编码不规范引起