Java编程思想笔记0x15

并发(二)

共享受限资源

不正确地访问资源

class Even {
    private int i = 0;

    public int next() {
        ++i; // [1]
        ++i;
        return i;
    }
}
  • 上面代码中,如果正确执行,那么next()方法返回的一定是偶数。但是在并发的条件下,[1]处如果线程被挂起,被另外一个线程调用该对象的next()方法时则会返回奇数。

使用synchronize进行同步控制

class Even {
    private int i = 0;

    public synchronized int next() {
        ++i; // [1]
        ++i;
        return i;
    }
}
  • 使用synchronized关键字进行同步控制,应将涉及竞争的数据成员都声明为private,仅允许通过方法来访问这些成员。然后将相关的方法声明为synchronized
  • 所有对象都自动含有单一的锁(也成为监视器)。当在对象上调用其任意的synchronized方法的时候,此对象都被加锁,此时该对象上的其他synchronized方法只有等前一个方法调用完毕并释放了锁之后才能被调用。
  • 一个任务可以多次获得对象的锁。JVM会跟踪加锁的次数,每当这个相同的任务在这个对象上获得锁时,相应的计数器会递增;每当任务离开一个synchronized方法,计数会递减;当计数为0时,锁被完全释放,此时其他任务可以使用此资源。
  • 针对每个类,也有一个锁(作为类的Class对象的一部分),所以synchronized static方法可以在类的范围内防止对static数据的并发访问。

使用显式的Lock对象

  • Lock对象必须被显式地创建、锁定和释放。相比起synchronized,代码缺乏优雅性,但是更加灵活;并且,如果使用synchronized时某些事物失败的将会抛出异常,没有机会进行清理工作,而使用Lock对象可以在finally子句中进行清理工作。
class Even {
    private int i = 0;
    private Lock lock = new ReentrantLock();

    public int next() {
        lock.lock();
        try {
            ++i;
            ++i;
            return i;
        } finally {
            lock.unlock();
        }
    }
}

原子性和可视性

  • 原子操作是不能被线程调度机制中断的操作,一旦操作开始,那么它一定在可能发生的上下文切换之前执行完毕。

当变量声明为volatile时,变量将具备以下两个特性:

  1. 保证此变量对所有线程的可见性,即一个线程修改了volatile变量后,其余线程可以立即获得修改后的值。

  2. 禁止指令重排序优化,即设置内存屏障,保证volatile变量修改更新到所有CPU上。

  • 在非volatile上的原子操作不必刷新到主存中去,因此其他读取该域的任务也不必看到这个新值。
  • 如果多个任务在同时访问某个域,那么这个域就应该是volatile的,否则,这个域就应该只能经由同步来访问。同步也会导致向主存中刷新,因此如果一个域完全由synchronized方法或语句块来防护,那就不必将其设置为是volatile的。
  • 当一个域的值依赖于它之前的值,或者受到其他域的值的限制时,volatile将无法工作。

原子类

  • Java SE5引入了诸如AtomicIntegerAtomicLongAtomicReference等特殊的原子性变量类,提供原子性条件更新操作compareAndSet(expectedValue, updateValue),这是由硬件(CPU指令)支持的。

临界区

  • 如果只是希望防止多个线程同时访问方法内部的部分代码而不是防止访问整个代码,可以使用synchronized指定某个对象,此对象的锁将用于对指定代码段进行同步控制。这段代码被称为临界区,或者同步控制块。
  • 一般情况下,使用synchronized同步临界区时指定的同步对象为当前对象,即synchronized (this)。当然也可以指定其它对象,但注意确保所有任务都是在同一个对象上同步的。

终结任务

线程状态

  • 一个线程可以处于一下四种状态之一:
    1. 新建:当线程被创建时,它只会短暂地处于该状态。此时它已经得到了必需的系统资源,并进行了初始化,可以获得CPU时间了。之后调度器将把这个线程转变为可运行状态或者阻塞状态。
    2. 就绪:在这种状态下,只要调度器把时间片分配给线程,线程就可以运行,也就是说,线程可以运行也可以不运行。
    3. 阻塞:线程能够运行,但有某个条件阻止它运行。当线程处于阻塞状态时,调度器将会忽略线程,不会分配给线程任何CPU时间。直到线程重新进入了就绪状态,它才有可能执行操作。
    4. 死亡:处于死亡或终止状态的线程不再是可调度的,并且再也不会得到CPU时间,它的任务已经结束,或者不再是可运行的。任务死亡的通常方式是从run()方法返回,但是任务的线程还可以被中断。

进入阻塞状态

  • 一个任务进入阻塞状态,可能有如下原因:
    1. 通过调用sleep()使任务进入休眠状态,在这种情况下,任务在指定的时间内不会运行。
    2. 调用wait()使线程挂起,直到线程得到了notify()或者notifyAll()消息(或者在Java中等价的signal()signalAll()消息),线程才会进入就绪状态。
    3. 任务在等待某个输入/输出完成。
    4. 任务试图在某个对象上调用其同步控制方法,但对象锁不可用,因为另一个任务已经获取了这个锁。

中断

  • Thread类包含interrupt()方法,可以终止被阻塞任务,这个方法将设置线程的中断状态。如果一个线程已经被阻塞,或者试图执行一个阻塞操作,那么设置这个线程的中断状态将跑出InterruptedException。当抛出该异常或者该任务调用Thread#interrupted()时,中断状态将被复位。
  • 如果在Executor上调用shutdownNow(),那么它将发送一个interrrupt()调用给它启动的所有线程。如果希望只中断某个任务,那么需要通过调用submit()而不是executor()启动任务,这样可以持有线程的上下文。submit()将放回一个泛型Future<?>,可以在其上调用cancel(),由此中断某个任务。如果将true传递给cancel(),那么它就会有在该线程上调用interrupt()以停止这个线程的权限。
  • interrupt()不能中断正在试图获取synchronized锁或者试图执行I/O操作的线程,因为操作系统并未提供该功能
  • 可以通过调用Thread.interrupted()来检查中断状态,不仅仅可以得知interrupt()是否被调用过,还可以清除中断状态。
Author: SinLapis
Link: http://sinlapis.github.io/2019/07/28/Java编程思想笔记0x15/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.