Java并发编程之应用详解

个人博客请访问 http://www.x0100.top           

1. 并发编程介绍

1.1 并发的出现

单CPU时代,单任务在一个时间点只能执行单一程序。

多任务阶段,计算机能在同一时间点并行执行多进程。多个任务或进程共享一个CPU,并交由操作系统来完成多任务间对CPU的运行切换,以使得每个任务都有机会获得一定的时间片运行。

现代的计算机多核CPU,在一个程序内部能拥有多个线程并行执行,多个CPU同时执行该程序。一个进程就包括了多个线程,每个线程负责一个独立的子任务。

进程让操作系统的并发性成为可能,而线程让进程的内部并发成为可能。

一个进程虽然包括多个线程,但是这些线程是共同享有进程占有的资源和地址空间的。

进程是操作系统进行资源分配的基本单位,而线程是操作系统进行调度的基本单位。

1.2 并发编程优点

1)资源利用率更好

举例:

一个程序读取文件(5s)和处理文件(2s),处理2个文件。

5秒读取文件A
2秒处理文件A
5秒读取文件B
2秒处理文件B

总共需要14秒。读取文件的时候,CPU空闲等待读取数据,浪费CPU资源。

并发处理:

5秒读取文件A
5秒读取文件B + 2秒处理文件A
2秒处理文件B

总共需要12秒。当第二文件在被读取的时候,利用CPU的空闲去处理第一个文件。

2)程序设计在某些情况下更简单

如上述读取处理文件举例中,如果使用单线程实现,需要每个文件读取和处理的状态;而使用多线程,每个线程处理一个文件的读取和处理,不需要记录文件读取和处理状态,实现更简单。

3)程序响应更快

并发编程缺点

1)设计更复杂

由于多个线程是共同占有所属进程的资源和地址空间的,那么就会存在多个线程同时访问同一个资源的问题,可能导致线程安全问题。避免多线程编程中线程安全设计较复杂。

2)上下文切换的开销

CPU从执行一个线程切换到执行另外一个线程的时候,需要先存储当前线程的本地的数据,程序指针等,然后载入另一个线程的本地数据,程序指针等,最后才开始执行。这种切换称为上下文切换

对于线程的上下文切换实际上就是存储和恢复CPU状态的过程,它使得线程执行能够从中断点恢复执行。

虽然多线程可以使得任务执行的效率得到提升,但是由于在线程切换时同样会带来一定的开销代价,并且多个线程会导致系统资源占用的增加,所以在进行多线程编程时要注意这些因素。

3)增加资源消耗

线程在运行的时候需要从计算机里面得到一些资源。除了CPU,线程还需要一些内存来维持它本地的堆栈。它也需要占用操作系统中一些资源来管理线程。

2. 线程安全问题

竞态条件:当多个线程同时访问同一个资源,其中的一个或者多个线程对这个资源进行了写操作,对资源的访问顺序敏感,就称存在竞态条件。多个线程同时读同一个资源不会产生竞态条件。

临界区:导致竞态条件发生的代码区称作临界区。在临界区中使用适当的同步就可以避免竞态条件。

public class Counter {
    protected long count = 0;
    public void add(long value){
        this.count = this.count + value;
    }
}

多线程同时执行上面的代码可能会出错:多线程同时执行临界区代码this.count = this.count + value时,同时对同一资源this.count进行写操作,产生了竞态条件。

基本上所有的并发模式在解决线程安全问题时,都采用“序列化访问临界资源”的方案,即在同一时刻,只能有一个线程访问临界资源,也称作同步互斥访问。通常来说,是在访问临界资源的代码前面加上一个锁,当访问完临界资源后释放锁,让其他线程继续访问。

在Java中,提供了两种方式来实现同步互斥访问:synchronized和Lock。

3. 线程通信

线程通信的目标是使线程间能够互相发送信号。另一方面,线程通信使线程能够等待其他线程的信号。

通过共享对象通信

// 必须是同一个MySignal实例,通过共享变量hasDataToProcess通信
public class MySignal {
    protected boolean hasDataToProcess = false;

    public synchronized boolean hasDataToProcess() {
        return this.hasDataToProcess;
    }

    public synchronized void setHasDataToProcess(boolean hasData) {
        this.hasDataToProcess = hasData;
    }
}

单线程A完成某一操作M之后,调用setHasDataToProcess(true),将hasDataToProcess置为true,表示操作M完成。

线程B调用hasDataToProcess()获取hasDataToProcess为true,就知道操作M已经完成。

wait() - notify()/notifyAll()

//A线程调用doWait()等待, B线程调用doNotify()唤醒A线程
public class MyWaitNotify {
    MonitorObject myMonitorObject = new MonitorObject();

    public void doWait(){
        synchronized(myMonitorObject){
            try{
                myMonitorObject.wait();
            } catch(InterruptedException e){...}
        }
    }

    public void doNotify() {
        synchronized (myMonitorObject) {
            myMonitorObject.notify();
        }
    }
}

优化:

  1. 增加boolean wasSignalled,记录是否收到唤醒信号。只有没收到过唤醒信号时才可以wait,避免信号丢失导致永久wait。

  2. while()自旋锁,线程被唤醒之后可以保证再次检查条件是否满足,避免虚假信号。

public class MyWaitNotify3 {
    MonitorObject myMonitorObject = new MonitorObject();
    boolean wasSignalled = false;

    public void doWait() {
        synchronized (myMonitorObject) {
            while (!wasSignalled) {
                try {
                    myMonitorObject.wait();// 如果被虚假唤醒,再回while循环检查条件wasSignalled
                } catch (InterruptedException e) {
                }
            }
            wasSignalled = false;
        }
    }

    public void doNotify() {
        synchronized (myMonitorObject) {
            wasSignalled = true;
            myMonitorObject.notify();
        }
    }
}

4. 死锁

死锁:多个线程同时但以不同的顺序请求同一组锁的时候,线程之间互相循环等待锁导致线程一直阻塞。

如果线程1锁住了A,然后尝试对B进行加锁,同时线程2已经锁住了B,接着尝试对A进行加锁,这样线程1持有锁A等待锁B,线程2持有锁B等待锁A,就会发生死锁。

死锁可能不止包含2个线程,可以包含多个线程。如线程1等待线程2,线程2等待线程3,线程3等待线程4,线程4等待线程1。

举例:

public class Test {
    static Object lockObject1 = new Object();
    static Object lockObject2 = new Object();

    public static void main(String[] args) {
        new Thread() {
            @Override
            public void run() {
                synchronized (lockObject1) {
                    try {
                        Thread.sleep(500);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    synchronized (lockObject2) {
                        System.out.println(1);
                    }
                }
            }
        }.start();

        new Thread() {
            @Override
            public void run() {
                synchronized (lockObject2) {
                    synchronized (lockObject1) {
                        System.out.println(1);
                    }
                }
            }
        }.start();
    }
}

如何避免死锁?

1)按顺序加锁

多个线程请求的一组锁按顺序加锁可以避免死锁。

死锁:如果线程1锁住了A,然后尝试对B进行加锁,同时线程2已经锁住了B,接着尝试对A进行加锁,发生死锁。

解决:规定锁A和锁B的顺序,某个线程需要同时获取锁A和锁B时,必须先拿锁A再拿锁B。线程1和线程2都先锁A再锁B,不会发生死锁。

问题:需要事先知道所有可能会用到的锁,并对这些锁做适当的排序。

2)加锁时限(超时重试机制)

设置一个超时时间,在尝试获取锁的过程中若超过了这个时限该线程则放弃对该锁请求,回退并释放所有已经获得的锁,然后等待一段随机的时间再重试。

这段随机的等待时间让其它线程有机会尝试获取相同的这些锁,并且让该应用在没有获得锁的时候可以继续运行干点其它事情。

问题:

  1. 当线程很多时,等待的这一段随机的时间会一样长或者很接近,因此就算出现竞争而导致超时后,由于超时时间一样,它们又会同时开始重试,导致新一轮的竞争,带来了新的问题。

  2. 不能对synchronized同步块设置超时时间。需要创建一个自定义锁,或使用java.util.concurrent包下的工具。

3)死锁检测

主要是针对那些不可能实现按序加锁并且锁超时也不可行的情况。

每当一个线程获得了锁,会在线程和锁相关的数据结构中(比如map)将其记下。当一个线程请求锁失败时,这个线程可以遍历锁的关系图看看是否有死锁发生。

例如:线程1请求锁A,但是锁A这个时候被线程2持有,这时线程1就可以检查一下线程2是否已经请求了线程1当前所持有的锁。

如果线程2确实有这样的请求,那么就是发生了死锁(线程1拥有锁B,请求锁A;线程B拥有锁A,请求锁B)。

当检测出死锁时,可以有两种做法:

  1. 释放所有锁,回退,并且等待一段随机的时间后重试。(类似超时重试机制)

  2. 给这些线程设置优先级,让一个(或几个)线程回退,剩下的线程就像没发生死锁一样继续保持着它们需要的锁。

5. 嵌套管程锁死

线程1获得A对象的锁。
线程1获得对象B的锁(A对象锁还未释放)。
线程1调用B.wait(),从而释放了B对象上的锁,但仍然持有对象A的锁。
线程2需要同时持有对象A和对象B的锁,才能向线程1发信号B.notify()。
线程2无法获得对象A上的锁,因为对象A上的锁当前正被线程1持有。
线程2一直被阻塞,等待线程1释放对象A上的锁。
线程1一直阻塞,等待线程2的信号,因此不会释放对象A上的锁。

举例:

public class Lock {
    protected MonitorObject monitorObject = new MonitorObject();
    protected boolean isLocked = false;

    public void lock() throws InterruptedException {
        synchronized (this) {
            while (isLocked) {
                synchronized (this.monitorObject) {
                    this.monitorObject.wait();
                }
            }
            isLocked = true;
        }
    }

    public void unlock() {
        synchronized (this) {
            this.isLocked = false;
            synchronized (this.monitorObject) {
                this.monitorObject.notify();
            }
        }
    }
}

线程1调用lock()方法,Lock对象锁和monitorObject锁,调用monitorObject.wait()阻塞,但仍然持有Lock对象锁。

线程2调用unlock()方法解锁时,无法获取Lock对象锁,因为线程1一直持有Lock锁,造成嵌套管程锁死。

6. 重入锁死

如果一个线程持有某个对象上的锁,那么它就有权访问所有在该对象上同步的块,这就叫可重入。synchronized、ReentrantLock都是可重入锁。

如果一个线程持有锁A,锁A是不可重入的,该线程再次请求锁A时被阻塞,就是重入锁死。

重入锁死举例:

public class Lock {
    private boolean isLocked = false;

    public synchronized void lock() throws InterruptedException {
        while (isLocked) {
            wait();
        }
        isLocked = true;
    }

    public synchronized void unlock() {
        isLocked = false;
        notify();
    }
}

如果一个线程两次调用lock()间没有调用unlock()方法,那么第二次调用lock()就会被阻塞,这就出现了重入锁死。

7. 饥饿和公平

如果一个线程因为CPU时间全部被其他线程抢走而得不到CPU运行时间,这种状态被称之为饥饿。

导致线程饥饿原因:

  1. 高优先级线程吞噬所有的低优先级线程的CPU时间。

  2. 线程始终竞争不到锁。

  3. 线程调用object.wait()后没有被唤醒。

解决饥饿的方案被称之为公平性,即所有线程均能公平地获得运行机会。关于公平锁会在之后ReentrantLock中详细介绍。

总结

并发编程可以更好的利用CPU资源,更高效快速的响应程序,但是设计较复杂,并且上下文切换会造成一定的消耗。

并发编程中,由于多个线程同时访问同一个资源,可能造成线程安全问题,Java中可以通过synchronized和Lock的方式实现同步解决线程安全问题。

更好的发挥多线程的优势需要线程之间通信,常用的线程通信方式是通过共享对象的状态通信和wait()/notify()。

多个线程同时但以不同的顺序请求同一组锁的时候,线程之间互相循环等待锁导致线程一直阻塞,造成死锁。最常用的解决死锁的方式是按顺序加锁。

线程持有不可重入锁之后再次请求不可重入锁时被阻塞,就是重入锁死。

如果一个线程因为CPU时间全部被其他线程抢走而得不到CPU运行时间,这种状态被称之为饥饿。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章