Java核心技术读书笔记10-1 Java多线程并发与同步机制、锁与条件对象、Java内存模型RMM


1.多线程基础

1.1 进程与线程
进程:并发的进程数目并不受CPU数目制约。操作系统将CPU的时间片分配给每一个进程,造成并发的感觉,一个程序运行时即为一个进程单位。
线程:多线程扩展了多任务的概念,一个程序可以同时执行多个任务(如同时后台下载文件和操作界面),每一个任务称为一个线程。能同时运行一个以上线程的程序称为多线程程序。
每个进程拥有自己的一套变量,而线程则共享数据,这使得线程之间的通讯更容易。

1.2 使用线程
如果我们想要使用线程应该明白两个问题:
1.要是用线程完成什么工作?
Runnable是一个函数式接口,里面只有一个待实现的run方法。我们可以用一个类实现这个接口,然后将线程要完成的任务写入到重写的run()方法中。

public class NewThreadTask implements Runnable{
    @Override
    public void run() {
        System.out.println("这是一个新线程");
    }
}

2.怎么在执行时开启一个新线程?
使用Thread类构造一个对象,在传入Runnable后调用start方法就额外创建了一个线程并执行run()方法中的代码。

        Thread t1 = new Thread(new NewThreadTask());
        Thread t2 = new Thread(() -> System.out.println("lambda表达式")); //既然是传入函数式接口,自然也可以使用lambda表达式
        t1.start(); //主要不要直接调用runnbale对象的run方法,那样只是在当前线程中执行任务。
        t2.start();


从结果也可以看出来,各个线程是在抢占式执行。

你也可以构建一个Thread类的子类然后重写run方法,将任务放置到run中。这样构建该类对象后调用start方法也可以执行run方法。
public class NewThreadTask implements Runnable{
@Override
public void run() {
System.out.println("这是一个新线程");
}
}
但是,将线程与任务绑定的方式并不推荐,仍然应该像上述构建Runnable对象的方式使用线程以完成解耦。

暂停线程:Thread.sleep(long millis)使当前执行的线程休眠指定的毫秒值。

1.3 中断线程
如果你希望终止某个线程,该怎么做?当线程的run方法执行了最后一条语句后且经由return语句返回时,或者在方法中出现了没有捕获的异常时,线程将终止。
在Java 2引入了一个stop方法用于终止线程,但这个方法不安全,目前已被禁用。
Java中的线程时协作式的而非抢占式的,因此现在也没有可以强制终止线程的方法。不过,可以通过interrupt方法来请求终止线程。
当调用interrupt时,线程中断状态将被置位,这是每一个线程都具有的boolean标志。每个线程都应时不时的检查这个标志,以判断线程应否被中断。
没有任何语言方面的需求要求一个被中断的线程应该终止,被中断的线程应自己决定如何响应中断。如果某些线程很重要,那么甚至处理完异常之后还要继续执行,而不会理会中断。
一般情况下,线程代码的写法如下,即当线程未被中断时执行:

while(!Thread.currentThread().isInterrupted() && 其他条件){
  do more work //
}

不过,当一个被阻塞的线程(调用sleep或wait)调用interrupt方法时,将清除中断标记并产生Interrupted Exception
因此,当对sleep方法捕获InterrutedException时,可以选择重新中断当前线程或者不进行捕获直接抛出给调用者。

1.4 线程状态
线程的6种状态(可以通过线程对象调用getState方法得到):
· New(新创建)
· Runnable(可运行)
· Blocked(被阻塞)
· Waiting(等待)
· Timed waiting(计时等待)
· Terminated(被终止)

新创建:刚执行new Thread(r)结束

可运行:调用start方法后,进入可运行状态,由线程调度器管理,该状态的线程可能在运行也可能未运行,这取决于操作系统给线程提供的运行时间和调度方式

被阻塞与等待线程:此状态线程不活动,且消耗最少的资源直到线程调度器重新激活它。下面分情况讨论。
· 试图获得内部对象锁且该锁被其他线程持有,则进入阻塞状态。当锁被释放且线程调度器允许本线程持有时,该线程变为非阻塞态。
· 当线程等待某些条件时,如调用Object.wait或Thread.join方法,进入等待状态,此外使用java.util.current.locks包下的锁类和Condition等待对象时也会进入等待态。
· 当使用具有超时参数的方法时,进入计时等待状态,这一状态将保持到超时期满或收到适当的通知。
这些方法包括Thread.sleep、Object.wait、Thread.join、Lock.tryLock和Condition.await。

被终止:正常退出或出现未捕获异常。

join方法会使调用该方法的线程对象的父线程进入等待态直至该线程执行完毕。

1.5 线程属性
线程优先级:默认情况下,一个线程继承它父线程的优先级,可以调用setPriority提高或降低一个线程的优先级(MIN_PRIORITY到MAX_PRIORITY 1~10,默认优先级NORM_PRIORITY为5)。
线程调度器有机会选择新线程时,他首先选择具有较高优先级的线程。但是,线程优先级是高度依赖于系统的,JVM依赖于宿主平台的线程实现机制,即Java线程优先级会被映射到宿主平台的优先级,宿主优先级数可能更多也可能更少(Windows)或所有线程拥有同样的优先级(Linux)。

yield方法只是将虚拟机让给其它同等或更高优先级的线程,但线程的Runnable状态不会改变

守护线程:通过t.setDaemon(true/false)标识一个线程能否成为守护线程。守护线程是为其它线程提供服务的线程。当一个进程只剩守护线程时,程序会立即退出,因此,永远不要让守护线程访问固有资源,如文件、数据库。

未捕获异常处理器
run方法不能抛出任何受查异常,必须捕获。那么对于非受查异常的出现该怎么办呢?
在线程死亡之前,异常被传递到一个用于未捕获异常的处理器,该处理器必须属于一个实现Thread.UncaughtExceptionHandler接口的类,这个接口只有一个方法:
void uncaughtException(Thread t, Throwable e)
于是,可以用setUncaughtExceptionHandler为线程安装处理器,也可以用Thread类的静态方法setDefaultUncaughtExceptionHandler为所有线程安装一个默认的处理器。该处理器可以使用日志API发送未捕获异常报告到日志文件。

若不为线程安装处理器,此时处理器就是该线程的ThreadGroup对象(默认情况下,所有线程属于一个线程组,不建议在自己的程序中使用线程组)。
ThreadGroup类实现Thread.UncaughtExceptionHandler接口。其uncaughtException方法执行流程如下:
1.如果有父线程组,调用父线程组的uncaughtException方法
2.否则检查是否有全部线程的默认处理器,即上文提到的setDefaultUncaughtExceptionHandler,若设置了则调用
3.否则,若Throwable是ThrowDeath一个实例,什么也不做
4.否则将线程名字及Throwable轨迹栈输出到System.err上。

2.同步

2.1 原子操作与同步存取
丢失修改问题:在Java中,存在一些非原子性(即线程在一个操作过程可能会被剥处理器)的操作,如果多个线程使用这些操作访问数据可能会产生丢失修改问题。如:

int a = 1;
 开启多个线程访问a..
 各个线程进行 a += 1 操作;

a += 1实际上是a = a + 1;这个操作是两步:首先系统将a与1相加,然后把结果放到寄存器中。然后把结果从寄存器中取出覆盖回a。可以想到,若在第二步之前,有一个线程t1抢占处理器,然后对a进行了修改。那么当本线程重新抢占到处理器时会将寄存器的值覆盖回去,也就是说,会覆盖t1的修改结果,造成修改丢失。

2.2 锁对象
锁对象的本质就是使一段代码区域执行时处于单线程环境。

Lock aLock = new ReenTrantLock();
aLock.lock(); //加锁
try{
  ... //任何线程访问此区域都需要先获得锁,若锁已经被占有,则该线程只能进入等待态,等待其它线程释放锁。
}finally{
  aLock.unlock(); //释放锁,此语句必须放置在finally语句中,否则出现异常,锁永远释放不了
}

申请锁的语句不能放置到带有资源的try-catch语句中try后面的括号中,首先释放锁不是用的close方法。其次括号内部希望申请的是一个新变量,但对于锁来说,可能会有多个线程共享的情况。

2.3 可重入锁ReenTrantLock
可重入锁ReenTrantLock又称为对象锁,获得了一个可重入锁对象的线程可以继续访问使用同一个锁锁住的代码区域,因此,被一个锁锁住的方法可以调用另外一个使用相同锁的方法。可重入机制为每个锁维护一个持有计数来跟踪对被同一个锁锁住的方法的嵌套调用,每次线程获得锁时计数+1,每次释放锁时计数-1,由于锁是可重复获取的,只有当该锁的计数为0时代表该线程真正的不再调用同锁方法而释放了锁,这时就可由其它对象获取锁了。
详见:

2.4 条件对象
现在,存在一些需求,对于需要同步访问的一片代码区域可能要满足条件再执行。如银行转账:转账过程必须满足原子性,否则会产生丢失修改问题,但是传出的账户要满足余额大于所需要的的钱数才可以进行转账。那么该如何设计呢?
1.判断语句后加锁

if(余额足够){
  加锁 //这种方法是不行的,在条件满足的情况下,完全有可能被剥夺虚拟机。当重新获得虚拟机转账时,可能条件已经不满足了
  转账
  解锁
}

2.整体加锁

  加锁 
while(余额不足){
  等待 
}
  转账 //可以满足达到条件再转账,但不满足时会持续占有锁等待余额足够,这又使得别的线程没有机会向账户存款,这样就会一直等待下去。
  解锁

因此,需要使用一种方式,在加锁后,若不满足条件就挂起等待并且释放锁,当满足条件时就去竞争锁继续完成任务。这就是条件对象的应用,其对应的语句如下:

Condition aConditionObj = aLock.newCondition(); //获得锁的等待条件对象
aConditionObj.await(); //当前线程进入该条件的等待集,然后释放掉对应的锁。该语句一般应用于不满足条件的循环时
aConditionObj.signalAll(); //解除等待集中的线程,这些线程会再次正常竞争锁,竞争到锁的线程继续执行,其它线程处于得不到锁的等待状态。该语句一般应用于对等待条件变量的修改时,因为此时可能会有机会满足条件


书上的例子:

public class Bank {
    private final double[] accounts;
    private Lock bankLock;
    private Condition sufficientFun;
    public Bank(int n, double initialBalance){
        accounts = new double[n];
        Arrays.fill(accounts, initialBalance);
        bankLock = new ReentrantLock();
        sufficientFun = bankLock.newCondition();
    }

    public void transfer(int from, int to, double amount) throws InterruptedException {
        bankLock.lock();
        try{

            while (accounts[from] < amount){
                System.out.println(Thread.currentThread()+" 账号"+from+"现余额"+accounts[from]+"不足"+amount+",挂起");
                sufficientFun.await();
            }
            System.out.println(Thread.currentThread());
            System.out.println("账号"+ from +"开始转账");
            accounts[from] -= amount;
            accounts[to] += amount;
            System.out.println("转账成功!"+" 账号"+to+"当前余额为:"+accounts[to]);
            sufficientFun.signalAll();
        }finally {
            bankLock.unlock();
        }
    }
}
public class BankTransfer {
    public static void main(String[] args) throws InterruptedException {
        Bank bank = new Bank(10, 10);
        Thread r1 = new Thread(() -> {
            try {
                bank.transfer(0, 1 , 11);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        r1.start();
        Thread.sleep(100);
        Thread r2 = new Thread(() -> {
            try {
                bank.transfer(2, 0, 1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        r2.start();
    }
}

1.signalAll是解除所有等待集中的等待线程,还存在一个方法signal是随机解除一个等待集中的线程。不过这个方法使用是有危险的,因为随机唤醒的线程可能仍然不满足条件再次回到等待集。但此时,再也没有线程可以去唤醒其它线程了,程序将会死锁。
2.在等待集中的线程就算锁可用也不会去竞争锁,必须被signal或signalAll激活才可以。
3.线程被移出等待队列之后会去竞争锁,得不到锁处于等待状态,只要锁空闲就会继续去竞争锁,得到锁后进入可执行状态。
4.一个锁可以有多个条件对象,每个条件对象有自己的等待集,aConditionObj.await()会让当前线程进入aConditionObj的等待集。

2.5 synchronized关键字与内部对象锁
在Java中,每一个对象都有一个内部锁,synchronized关键字的作用就是可以对其作用的区域施加内部对象锁,施加对象锁后,其它线程都不能使用该对象的同步代码区域
synchronized可以作用到代码块、实例方法与静态方法上。

使用对象锁实现原子操作的方式被称为客户端锁定。
与Lock不同,等待内部对象锁会进入阻塞态。

synchronized作用于方法——同步方法
synchronized可以作用于方法上,此时其它线程不能访问该对象的同步方法。synchronized也可以施加到静态方法上,此时使用的锁是类对象(即.class对象)的内部锁,这时任何其他线程都不能使用该类的静态方法。

public synchronized void aMethod1(..){ //施加内部对象锁,其它线程不能访问该对象同步方法
  ...
}

public synchronized static void aMethod2(..){ //施加的是类的内部对象锁,其它线程不能访问该类的静态同步方法
  ...
}

对于条件对象,每一个内部对象锁只有一个相关的条件。类似与Lock的条件对象用法,你可以使用wait将一个线程添加到等待集中,notifyAll与notify可以解除等待集的等待线程(await/signal/signalAll)。

内部锁和条件存在一些局限,包括:
· 不能中断一个正在试图获得锁的线程
· 试图获得锁时不能设定超时
· 每个锁条件对象单一
· 无法得知是否成功获得锁
那么,该如何选择锁的使用呢?内部锁还是可重入锁?
· 首先,如果非特殊要求,最好两者都不用,而是使用java.util.concurrent
· 其次可以使用编写更简单的synchronized关键字,减少出错的几率
· 如果需要使用Lock与Condition的独有特性时再去使用

synchronized作用于代码块——同步代码块

synchronized(anObj){
  ... //所有传入anObj对象的调用线程都会被阻塞
}

示例如下:

public class ObjectLock {
    public static void main(String[] args) {
        Object obj = new ArrayList<>();
        Thread t1 = new Thread(() -> {
            try {
                new ObjectLock().test(obj, 5, "t1");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        t1.start();

        Thread t2 = new Thread(() -> {
            try {
                new ObjectLock().test(obj, 5, "t2");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        t2.start(); //上述两个线程将会进行同步

        Thread t3 = new Thread(() -> {
            try {
                new ObjectLock().test(new String(), 5, "t3");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        t3.start(); //使用其它的对象锁,不会发生同步

    }
    public void test(Object obj, int n, String name) throws InterruptedException {
        synchronized (obj){
            for(int i = 0; i < n; i++){
                System.out.println(name+ ":" + i);
                Thread.sleep(100);
            }
        }
    }
}

括号中的对象可以是一个普通对象,用法如上所示,也可以是this关键字与.class对象
使用的this关键字即为隐变量,也就是方法的调用者。换句换说,此时同一个对象调用的方法都会被同步,如:obj1.test(....)与obj1.test(....)会被同步
而若使用的是.class对象,由于所有类对应的是一个class对象。所以此时所有类对象调用的方法都会被同步,也就是上面实例中,如果同步处写为synchronized (this.getClass()),那么三个线程调用方法时都会被同步。

关于Vector:虽然Vector类是同步的,但我们对其进行存取时本线程仍然可能被剥夺处理机导致丢失修改。所以,是否想到可以对Vector对象采用内部锁的方式加锁?

public void transfer(Vector accounts, int from, int to, int amount){
  synchronized(accounts){
    accounts.set(from, accounts.get(from) - amount);
    accounts.set(to, accounts.get(to) + amount);
  }
}

这段代码实现了对accounts对象的同步访问,但是,是否可以保证所有其余的对accounts的修改方法都应用了对象锁而达到只要对这个Vector对象进行修改就是同步修改?Vector类并没有给出这样的说明。所以客户端锁定是脆弱的。

2.6 监视器
监视器概念是对于为了不需要程序员考虑如何加锁的情况下,就可以保证多线程的安全性理念最好的解决方案。如果用Java术语来讲,其特性如下:
· 监视器是只包含私有域的类
· 每个监视器类的对象有一个相关的锁
· 使用该锁对所有方法加锁
· 该锁可以有任意多个相关条件
对于Java来说,synchronized关键字可以隐式的满足一部分监视器的特性,但不是一个纯粹的监视器类,因为:
· 域可以不是private
· 方法可以不是synchronized的
· 内部锁对对象可用

2.7 volatile域与Java内存模型(JMM)
由于Java需要跨平台的性质,Java在虚拟机中定义了一种的Java内存模型(JMM)来规定Java程序对于变量的访问规则以屏蔽各个硬件平台与操作系统之间的差异。JMM规定所有程序中的变量都必须存储在主存中以方便各个线程间的通信,对于每个线程都包含一个更高速的工作内存,线程所用变量从主存中拷贝,且对于变量的操作必须在自己的工作内存中进行
因此在多线程环境下由于程序的运行特点,想要能得到正确的运行结果,需要保证变量的可见性、原子性和有序性。
可见性:保证线程对变量修改过后可以立即将结果从工作内存刷新回主存。
原子性:保证在对变量进行修改时线程不可以被剥夺处理机。
有序性:保证重排过后的指令执行结果是正确的(编译器和处理器会考虑吞吐量和执行效率而对没有数据依赖的指令重排序执行,这在单线程环境下结果是保证正确的,但多线程环境下可能会产生问题)。
volatile关键字修饰的变量在每次读写时会加入特定的内存屏障放置指令重排,且可以保证每次直接从主存中读取,修改完毕之后也会将结果返回到主存中。所以使用该变量时可以保证可见性和有序性,但无法保证原子性,即无法保证线程使用时不被剥夺处理机。

对于Lock与synchronized关键字要求同步代码区域同一时间只允许一个线程访问变量,实现了原子性。而这个过程相当于将环境变为了单线程环境,保证了有序性和可见性。

2.8 final变量:final变量在构造完才可见,所以任何时候都可以保证安全访问变量存储的值。

2.9 java.util.concurrent.atomic包
该包中有很多保证一些操作具有原子性机器级指令的类。如AtomicInteger类的incrementAndGet和decrementAndGet方法可以保证一个整数进行带有原子性的自增和自减操作。如果涉及更复杂的操作就需要使用compareAndSet方法。手动使用这些类实现原子操作会比较复杂,但是这些方法会映射到处理器操作,会比使用锁更快。

2.10 线程局部变量
ThreadLocal类可以为各个线程提供各自的实例。该类可以对指定的类对象构造出一个ThreadLocal类型的对象,T即为被包装对象的类。之后在线程中使用对该对象首次使用get方法时,会调用初始化方法返回指定类对于这个线程的实例,如果以后再次调用get方法都会返回这个实例。

2.11 锁测试与超时机制
在线程使用lock方法申请锁的时候,如果申请不到会进入等待状态。其实还可以提供一种方式,允许线程返回申请结果,根据申请结果决定要做什么而不去等待。或者设定一个时间参数,在时间内尝试申请锁(等待),超时之后返回false。这两种方式分别对应tryLock()方法和tryLock(long time, TimeUnit unit)方法。
同样地,使用条件对象的await方法时也可以指定一个时间,若等待时间超过了指定时间就自动解除等待状态。

TimeUnit是一个枚举类型,其值包括SECONDS、MILLISECONDS、MICROSECONDS和NANOSECONDS等时间单位。例如:alock.tryLock(100, TimeUnit.MILLISECONDS)为在100ms内尝试获得锁,未获得返回false

lock方法使线程在等待时也不能被中断,而tryLock在等待状态时被中断(或等待前中断标志为true)将会抛出InterruptedException异常,这种特性可以用来打破死锁。如果需要这个特性,但又不想指定一个时间期限可以使用lockInterruptibly()方法,它会返会一个可以中断等待线程的锁。相当于一个时间无限的tryLock()方法。

对于await方法,在线程等待时,若产生中断将会抛出一个InterruptedException。如果希望中断时线程继续等待,则可以使用awaitUninterruptibly方法。

2.12 读/写锁(ReentrantReadWriteLock)
ReentrantReadWriteLock读写锁类与可重入锁一样都在java.util.concurrent.locks包下。该锁可以根据需要对读写操作进行划分然后施加不同的锁。读写锁适用于读多写少的情况,ReentrantReadWriteLock类同时维护一个读锁与写锁,当有线程进入读锁封锁区域时,其它线程可以进入读锁封锁区域,但进入写锁封锁区域则需要等待释放读锁。当有线程进入写锁封锁区域时,任何线程对于写锁和读锁封锁区域都不能进入。示例如下:

public class RWLockTest {
    private ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
    private int value = 0;
    public int read(String name){
        rwl.readLock().lock(); //读锁封锁区,有线程进入此区域时,其它线程只能进入读锁封锁区。
        int i = 3;
        try{
            while(i-- > 0){
                System.out.println("线程-" + name + "读取中..");
                Thread.sleep(100);
            }
            return value;
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            rwl.readLock().unlock();
        }
        return -1;
    }
    public void writeAdd(String name){
        rwl.writeLock().lock(); //读锁封锁区,有线程进入此区域时,其它线程任何区域都不可进入。
        int i = 3;
        try{
            while(i-- > 0){
                System.out.println("线程-" + name + "写入值..");
                Thread.sleep(100);
            }
            this.value++;
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            rwl.writeLock().unlock();
        }
    }
    public static void main(String[] args) throws InterruptedException {
        RWLockTest rwl = new RWLockTest();
        //模拟一个线程在进行读取
        Thread t1 = new Thread(() -> {
            rwl.read("read1");
        });
        t1.start();
        //此时一个线程希望写入值, 在t1未完成读取时这两个线程将会被同步
        Thread t2 = new Thread(() -> {
            rwl.writeAdd("write1");
        });
        t2.start();
        //又有一个线程希望进行读取,因为我们在读取时进行了sleep操作,所以第一个线程还没有读完,可以看到结果,两个读线程将会交替执行,不会同步
        Thread t3 = new Thread(() -> {
            rwl.read("read2");
        });
        t3.start();
        //由于读写互斥,读完之后写线程才可以修改值,我们在添加一个写线程
        Thread t4 = new Thread(() -> {
            rwl.writeAdd("write2");
        });
        t4.start();
        t1.join(); //等待子线程执行完毕再查看最终结果
        t2.join();
        t3.join();
        t4.join();
        System.out.println("最终结果为:" + rwl.read("主线程"));
    }
}


2.13 stop与suspend方法被弃用原因
stop方法与suspend方法都试图控制一个给定线程的行为。
对于stop方法,调用它将会不顾后果的终止线程。由于线程执行的不可知性,你无法知道当前线程执行到哪,所以直接终止线程可能会造成对象不一致的状态(如银行转账过程中线程突然停止)。
对于suspend方法,本线程调用它将会挂起一个线程,之后只能被再由本线程调用resume恢复。被suspend方法挂起的线程会占据持有的锁,如果当前线程之后需要等待这个锁,那么将会发生死锁。(为什么合理地使用await/wait不会产生死锁。以为这两个方法实际上表示的是不满足条件,自己进行等待同时释放锁,之后再由其它可运行的线程唤醒。但对于suspend,在挂起完别的线程之后,本线程也有可能由于被挂起的线程占有的资源而进入等待状态)