Java并发编程原理与实战七:线程带来的风险

在并发中有两种方式,一是多进程,二是多线程,但是线程相比进程花销更小且能共享资源。但使用多线程同时会带来相应的风险,本文将展开讨论。

一、引言

多线程将会带来几个问题:

1、安全性问题

线程安全性可能是非常复杂的,多线程中的操作执行顺序是不可预测的,甚至会产生奇怪的结果,另外由于存在指令重排序的可能,因此实际情况会很糟糕。

有一种常见的并发安全问题叫“竞态条件”。由于多个线程要共享相同的内存地址空间,并且是并发运行,因此它们可能会访问或修改其他线程正在使用的变量。这带来的极大的便利,但是也有巨大的风险:线程会由于无法预料的数据变化而发生错误。当多个线程同时访问和修改相同的变量时,将会在串行编程模型中引入非串行因素,而这种非串行性是很难分析的。要使多线程程序的行为可预测,必须对共享变量的访问操作进行协同,这样才不会在线程之间发生彼此干扰。

    例如使用 synchronized 关键字来同步;

    public class Value {

        @GuardedBy("this") private int value;

        public synchronized int getNext(){

          return value++;

        }

    }

    @GuardedBy(lock)  指明对象或变量受哪个锁保护

2、活跃性问题

    线程会导致一些在单线程程序中不会出现的问题,那就是活跃性问题。

    自己的理解的“活跃性问题”:即线程的活跃性,线程能够有意义的在正常的按设计工作。

    安全性问题的含义是“永远不发生糟糕的事情”,而活跃性则关注另外一个目标:“某件正确的事情最终会发生”。当某个操作无法继续执行下去时,就会发生活跃性问题。在串行程序中,活跃性问题的一种就是无意中造成的无限循环,使得不能按照设计的工作进行,执行不到后面的代码。或者由于资源竞争而导致的死锁等。

    活跃性问题同安全性问题同样难以分析,因为依赖于不同线程的事件发生时序,在测试中不总是能够重现。

3、性能问题

    活跃性意味着某件正确的事情会中会发生,但却不够好,因为我们希望正确的事情尽快发生。性能问题包括多个方面,例如服务时间过长,响应不灵敏,吞吐率过低等等。

    所以多线程需要良好的设计来提升线程的性能,但无论如何线程总会带来额外的开销,由于多线程之间的调度会频繁地出现上下文切换操作,保存和恢复执行上下文,线程之间共享数据时需要同步等等。

二、常见的活跃性问题

关于线程的安全性问题,后面会细讲,因为安全性问题是多线程最大的问题,也是后面引申出同步,通信等相关问题的来源,因此这里本篇不进行讲解,本篇首先了解活跃性问题。

一个并发应用程序能及时执行的能力称为活跃性。

1、死锁

死锁描述了这样一种情景,两个或多个线程永久阻塞,互相等待对方释放资源。下面是一个例子。

Alphone和Gaston是朋友,都很讲究礼节。礼节有一个严格的规矩,当你向一个朋友鞠躬时,你必须保持鞠躬的姿势,直到你的朋友有机会回鞠给你。不幸的是,这个规矩没有算上两个朋友相互同时鞠躬的可能。

下面的应用例子,模拟了这个可能性:

 static class Friend {
        private final String name;
        public Friend(String name) {
            this.name = name;
        }
        public String getName() {
            return this.name;
        }
        public synchronized void bow(Friend bower) {
            System.out.format("%s: %s"
                + "  has bowed to me!%n",
                this.name, bower.getName());
            bower.bowBack(this);
        }
        public synchronized void bowBack(Friend bower) {
            System.out.format("%s: %s"
                + " has bowed back to me!%n",
                this.name, bower.getName());
        }
    }

    public static void main(String[] args) {
        final Friend alphonse =
            new Friend("Alphonse");
        final Friend gaston =
            new Friend("Gaston");
        new Thread(new Runnable() {
            public void run() { alphonse.bow(gaston); }
        }).start();
        new Thread(new Runnable() {
            public void run() { gaston.bow(alphonse); }
        }).start();
    }
}

运行结果可以看到两个线程都阻塞到了第13行即当它们尝试调用bowBack方法时,没有哪个阻塞会结束,因为每个线程都在等待另一个线程退出bow方法。产生死锁。

2、饥饿

饿是指当一个线程不能正常的访问共享资源并且不能正常执行的情况。这通常在共享资源被其他“贪心”的线程长期时发生。举个例子,假设一个对象提供了一个同步方法,这个方法通常需要执行很长一段时间才返回。如果一个线程经常调用这个方法,那么其他需要同步的访问这个对象的线程就经常会被阻塞。

3、活锁

一个线程通常会有会响应其他线程的活动。如果其他线程也会响应另一个线程的活动,那么就有可能发生活锁。同死锁一样,发生活锁的线程无法继续执行。然而线程并没有阻塞——他们在忙于响应对方无法恢复工作。这就相当于两个在走廊相遇的人:Alphonse向他自己的左边靠想让Gaston过去,而Gaston向他的右边靠想让Alphonse过去。可见他们阻塞了对方。Alphonse向他的右边靠,而Gaston向他的左边靠,他们还是阻塞了对方。

4、小节

    • 死锁:两个线程相互等待对方释放资源
    • 饥饿: 
      活锁:活锁指的是任务或者执行者没有被阻塞,由于某些条件没有满足,导致一直重复尝试—失败—尝试—失败的过程。处于活锁的实体是在不断的改变状态,活锁有可能自行解开。
      • 多线程并发时,优先级低的线程永远得不到执行
      • 线程被永久阻塞在等待进入同步块的状态
      • 等待的线程永远不被唤醒

如何避免饥饿问题

对于优先级引发的饥饿问题,用setPriority设置线程的优先级。 
对于永久阻塞引发的饥饿问题,用锁来代替synchronized。

三、避免活跃性危险

参考:https://blog.csdn.net/qq_28191657/article/details/79355091

一、死锁:在线程A持有锁L并想获得锁M的同时,线程B持有锁M并尝试获得锁L,那么这两个线程将永远等待下去,这种情况下就是最简单的死锁。

1.锁顺序死锁:两个线程试图以不同的顺序来获得相同的锁,比如线程A线锁住锁L,尝试锁住M,而线程B线锁住了M,尝试锁住L,这样就发生死锁了,如果按照相同的顺序来请求锁,那么就不会出现循环的加锁依赖性。比如都是按照现锁住L后锁住M的顺序来获得锁,那就不会死锁。

2.动态的锁顺序死锁:比如转账的方法transferMoney(myAccount,yourAccount,10)和transferMoney(yourAccount,myAccount,10),同时执行这两个转账,X转给Y,Y转给X,如果执行的时序不当,也会发生死锁。而且这种死锁我们无法控制参数的顺序,所以只能定义锁的顺序。而在制定锁的顺序上,我们可以使用System.identityHashCode方法,该方法将返回由Object.hashCode返回的值。在极少的情况下,会有相同的散列值,在这种时候,我们就必须使用“加时赛”锁,在获得锁之前,线获得这个“加时赛”锁,从而保证每次只有一个线程以位置的顺序获得这个锁。

3.在协作对象之间发生的死锁:比如说两个类Taxi出租车和Dispacher车队,Taxi有更新地点的方法setLocation,车队显示获取每个车的位置的方法getImage,当出租车到达某个位置要使用setLocation,并且需要告诉车队,那么它需要线活动Taxi的锁,然后获得Dispacher的锁,而车队显示每个车的位置的方法需要先获得Dispacher的锁,再获得Taxi的锁,这就有可能产生死锁。而这个死锁比较难以找到,可以使用开放调用来解决,也就是在调用某个方法时不需要持有锁。

4.资源死锁:当多个线程在相同资源集合上等待时,也会发生死锁。例如资源池的死锁,还有一种资源死锁就是线程饥饿死锁。

检查死锁的步骤:首先找出在什么地方获取多个锁,然后对所有这些实例进行全局分析,从而确保它们在整个程序中获取锁的顺序都保持一致。尽可能的使用开放调用,这能极大的简化分析过程。如果所有调用都是开放调用的,那么就通过代码审查就可以找到。

支持定时的锁:利用显式锁,可以指定一个超时时限,在等待超过时间后tryLock会返回一个失败信息。用这种方法来消除死锁发生的可能性。

JVM会通过线程转储来帮助识别死锁的发生。

二、饥饿:放线程无法访问它所需要的资源而不能继续的时候,就发生了饥饿。引发饥饿最常见的就是CPU时钟周期,还有执行一些无法结束的结构比如死循环之类的。

三、糟糕的响应性

四、活锁:尽管不会阻塞线程,但是也不能继续执行。因为线程将不断重复执行相同的操作,而且总会失败。活锁通常发生在事务消息的应用程序中:如果不能成功的处理某个消息,那么消息处理机制将回滚整个事务,并将它重新放到队列的开头,如果消息处理器在处理某种特定类型的消息时存在错误导致它失败,那么每当这个消息从队列中取出并传递到存在错误的处理器时,都会发生事务回滚。由于这条消息又被放到队列开头,因此处理器将被反复调用,并返回相同的结果。

要解决活锁问题,需要在重试机制中加入随机性。也就是让一直循环在做某个操作的事务进行一段随机性等待,就可以解决活锁问题。

这里补充一下显示锁的知识:

在java5.0以前协调堆共享对象的访问时可以使用synchronized和volatile,在这之后添加了一种新的机制:ReentrantLock。

Lock接口中定义了一组抽象的加锁操作,与内置锁不同,Lock提供了一种无条件的、课轮询的、定时的以及课中断的锁获取操作。ReentrantLock实现了Lock接口,并且提供了跟synchronized一样的互斥性和内存可见性。ReentrantLock跟synchronized一样都有进入同步代码块和退出同步代码块的内存语义,还提供给了一样的可重入加锁语义。

ReentrantLock使用比synchronized复杂:需要在finally块中释放锁,如果没有释放锁,将很难追踪到最初发生错误的位置,因为没有记录释放锁的位置和时间。

可轮询的与可定时的锁获取模式是由tryLock方法实现的,而他们就避免了死锁的发生。定时的锁获取操作能在带有时间限制的操作中使用独占锁,可中断的锁获取操作同样能在可取消的操作中使用加锁。lockInterruptibly方法能够在获取锁的同时保持对中断的响应。

ReentrantLock的构造函数中提供了公平锁和非公平锁,在公平的锁上,线程将按照它们发出请求的顺序来获得锁,但在非公平的锁上,则允许插队:当一个线程请求非公平锁时,如果在发出请求的同时,该锁的状态变为可用,则这个线程跳过队列直接获得这个锁。非公平锁的性能比公平锁高。

synchronized的局限性:1.无法中断一个正在等待获取锁的线程,2.无法在请求一个锁时无限的等待下去。3.无法实现非阻塞结构的加锁规则。

ReentrantLock,提供了定时的锁等待、可中断的锁等待、公平性,以及实现非块结构的加锁,但是它的危险性比synchronized要高,比如忘记finally中unlock,而且使用synchronized,线程转储中能给出哪些调用帧中获得了哪些锁,并且能够检测和识别发生死锁的线程,但是使用ReetrantLock的话,JVM就没有这个支持了,所以还是使用synchronized,当遇到内置锁不合适的时候,才用到ReentrantLock。

读写锁:允许多个读操作同时进行,但每次只允许一个写操作。和Lock一样,ReadWriteLock也有很多不同的实现方式。

ReentrantReadWriteLock为写入锁和读取锁提供了可重入的加锁语义,在构造时也可以选择公平性,在公平锁中,等待时间最长的线程将优先获得锁。如果这个锁由读线程持有,而另一个线程请求写入锁,那么其他线程都不能获得读取锁,直到写线程使用完并且释放了写入锁。在非公平的锁中,线程获取访问许可是不确定的。写线程降级为读线程是可以的,但是从读线程升级为写线程是不可以的。(这样会导致死锁)

四、线程活跃性问题解决方法

这个本文不进行细致讨论,可以参考其他人写的:

https://blog.csdn.net/chjttony/article/details/46651543

原文地址:https://www.cnblogs.com/pony1223/p/9358050.html