Java多线程笔记(一):JMM与基础关键字

JMM特性一览

Java Memory Model的关键技术点都是围绕着多线程的原子性、可见性和有序性来建立的。因此我们首先需要来了解这些概念。

原子性(Atomicity)

原子性是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个人操作一旦开始,就不会被其他的线程干扰。

比如对一个静态全局变量int i,两个线程同时对它赋值,线程A给他赋值1,线程B给它赋值为-1.那么不管这么2个线程以合作方式、何种步调工作,i的值要么是1,要么是-1。线程A和B之间是没有干扰的。这就是原子性的一个特点,不可被中断。

可见性(Visibility)

可见性是指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道这个修改。显然,对于串行程序来说,可见性问题是不存在的。因为你在任何一个操作步骤中修改了某个变量,那么在后续的步骤中,读取这个变量的值,一定是修改后的新值。

有序性(Ordering)

有序性问题是三个问题中最难理解的。对于一个线程的执行代码而言,我们总是习惯地认为代码的执行是从先往后,依次执行。这么理解也不是说完全错误,因为就一个线程内而言,确实会表现成这样。但是,在并发时,程序的执行可能就会出现乱序。给人直观的感觉就是:写在前面的代码,会在后面执行。然而有序性的问题的原因因为是程序在执行时,可能会进行指令重排,重排后的指令与原指令的顺序未必一致。 那么在这里由于篇幅关系就不在展开介绍,有兴趣的读者可以自行搜索Java指令重排CPU流水线等资料。

哪些指令不能重排——Happen-Before规则

虽然Java虚拟机和执行系统会对指令进行一定的重排,但是指令重排是有规则的,并非所有的指令都可以随便改变位置。原则基本包括以下:

  1. 程序顺序原则:一个线程内保证语义的串行性

      a=1;
      b=a+1;
      //第二条语句依赖于第一条执行结果。所以不允许指令重排。
  2. volatile规则:volatile变量的写,先发生与读,这保证了volatile变量的可见性。一般用volatile修饰的都是经常修改的对象。
  3. 锁规则:解锁(unlock)必然发生在随后的加锁(lock)前
  4. 传递性:A先于B,B先于C,那么A必然先于C
  5. 线程的start()方法先于它的每一个动作
  6. 线程的所有操作先于线程的终结(Thread.join())
  7. 线程的中断(interrupt())先于被中断线程的代码
  8. 对象的构造函数执行、结束先于finalize()方法

Java多线程

MultiThreadStates.png

线程所有的状态都在Thread.State枚举类中定义:

public enum State {
    /**
    * 表示刚刚创建的线程,这种线程还没开始执行。
    **/
    NEW,
    /**
    * 调用start()方法后,线程开始执行,处于RUNNABLE状态,
    * 表示线程所需要的一切资源以及准备好。
    **/
    RUNNABLE,
    /**
    * 当线程遇到synchronized同步块,就进入了BLOCKED阻塞状态。
    * 这时线程会暂停执行,直到获得请求的锁。
    **/
    BLOCKED,
    /**
    * WAITING和TIMED_WAITING都表示等待状态,他们是区别是WAITING表示进入一个无时间限制的等待
    * TIMED_WAITING会进入一个有时间限制的等待。
    * WAITING的状态正是在等待特殊的事件,如notify()方法。而通过join()方法等待的线程,则是等待目标线程的终止。
    * 一旦等到期望的时间,线程就会继续执行,进入RUNNABLE状态。
    * 当线程执行完后进入TERMINATED状态,表示线程执行结束。
    **/
    WAITING,
    TIMED_WAITING,
    TERMINATED;
}

线程的基本操作

新建线程

新建线程很简单。只要使用new关键字创建一个线程对象,并且将其start()起来即可。start()方法额就会新建一个线程并让这个线程执行run()方法。

常见就是有人直接对一个线程对象执行run()方法,那么只会在当前的线程中串行执行run()中的代码

最后要说的是,默认的Thread.run()就是直接调用内部的Runnable接口。因此,使用Runnable接口告诉线程该做什么,更为合理。

终止线程

Stop()方法是用不得的,会直接终止运行中的线程,并立刻释放锁。比如一个线程写数据到一般被中止,则会写坏。

那么最简单的方法可以考虑给线程做一个死循环,然后对一个类似Flag的变量进行判断,变量变化时退出循环。JDK所提供的线程中断也是类似于此。

线程中断

线程中断是重要的线程协作机制,中断就是让线程停止执行,但这个停止执行非stop()的暴力方式。JDK提供了更安全的支持,就是线程中断。
线程中断并不会使线程立即停止,而是给线程发送一个通知,告诉目标线程有人希望你退出。至于目标线程接到通知后什么时候停止,完全由目标线程自行决定。这点很重要,如果线程接到通知后立即退出,我们就又会遇到类似stop()方法的老问题。
与线程有关的三个方法,

  1. 中断线程
  2. void Thread.interrupt()

说明:Thread.interrupt() 是一个实例方法,他通知目标线程中断,也就是设置中断标志位。中断标志位表示当前线程已经被中断了。

  1. 判断是否被中断
  2. boolean Thread.isInterrupted()

说明:Thread.isInterrupted() 也是实例方法,他判断当前线程是否被中断(通过检查中断标志位)

  1. 判断是否被中断,并清除当前中断状态
  2. static boolean Thread.interrupted()

说明:Thread.interrupted() 是静态方法,判断当前线程的中断状态,但同时会清除当前线程的中断标志位状态。

Thread.sleep()方法会让当前线程休眠若干时间,它会抛出一个interruptedException中断异常。interruptedException是必须被捕获的——当线程在sleep时,如果被中断,这个异常就产生。

public class InterruptExample {

    public static void main(String [] a) throws InterruptedException{

        Thread t1 = new Thread("线程小哥 - 1 "){
            @Override
            public void run() {
                while (true){
                    /**
                     * 必须得判断是否接受到中断通知,如果不写退出方法,也无法将当前线程退出.
                     */
                    if (Thread.currentThread().isInterrupted()){
                        System.out.println(Thread.currentThread().getName() + " Interrupted ... ");
                        break;
                    }

                    try {
                        /**
                         * 处理业务逻辑花费10秒.
                         * 而在这时,主线程发送了中断通知,当线程在sleep的时候如果收到中断
                         * 则会抛出InterruptedException,如果在异常中不处理,则线程不会中断.
                         *
                         */
                        Thread.sleep(10000);
                    } catch (InterruptedException e) {
                        System.out.println("线程在睡眠中遭到中断....");
                        /**
                         * 在sleep过程中,收到中断通知,抛出异常.可以直接退出线程.
                         * 但如果还需要处理其他业务,则需要重新中断自己.设置中断标记位.
                         * 这样在下次循环的时候 线程发现中断通知,才能正确的退出.
                         */
                        Thread.currentThread().interrupt();
                    }

                    Thread.yield();
                }
            }
        };

        t1.start();
        try {
            /**
             * 处理业务500毫秒
             * 然后发送中断通知,此时t1线程还在sleep中.
             */
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        /**
         * 给目标线程发送中断通知
         * 目标线程中必须有处理中断通知的代码
         * 否则,就算发送了通知,目标线程也无法停止.
         */
        t1.interrupt();
    }
}

等待(wait)和通知(notify)

为了支持多线程之间的协作,JDK提供了两个非常重要的等待方法wait()和nofity()方法。这两个方法并不是Thread类中的,而是Object类,这意味着任何对象都可以调用这两个方法。

这两个方法的签名如下:

public final void wait() throws InterruptedException
public final native void notify()

如果一个线程调用了object.wait()方法,那么这个线程就会停止执行而转为等待状态,进入obj对象的等待队列。这个等待队列可能有多个线程,因为系统运行多个线程同时等待同一个对象。其他线程调用obj.notify()方法时,它就会从等待队列中随机选择一个线程并将其唤醒。注意这个选择是不公平的,是随机的。

object.wait()方法并不是可以随便调用。它必须包含在对应的synchronized语句中。无论是wait还是notify都必须首先获得目标对象的一个监视器 。如下图,显示了wait()和nofity的工作流程细节。其中T1和T2表示两个线程。T1在正确执行wait方法后,首先必须获得object对象的监视器。而wait方法在执行后,会释放这个监视器,这样做的目的使得其他等待object对象上的线程不至于因为T1的休眠而全部无法正常执行。

waitAndNotifyImagesNo1.png

线程T2在notify()调用前,也必须获得object的监听器。所幸,此时T1已经释放了这个监视器。因此,T2可以顺利获得object的监视器。接着,T2执行了notify()方法尝试唤醒一个等待线程,这里假设唤醒了T1。T1在被唤醒后,要做的第一件事并不是执行后续的代码,而是要尝试重新获得object的监视器。而这个监视器也正是T1在wait()方法执行前所持有的那个。如果暂时无法获得,T1还必须要等待这个监视器。当监视器顺利获得后,T1才可以真正意义上的继续执行。

注意::Object.wait()和Thread.sleep()方法都可以让线程等待若干的时间。除了wait()可以被唤醒外,另一个最主要的区别就是wait()方法会释放目标对象的锁,而Thread.sleep()方法不会释放任何资源。

挂起(suspend)和继续执行(resume)线程

不推荐使用suspend()去挂起线程 的原因是因为suspend()在导致线程暂停的同时,并不会去释放任何资源。此时,其他任何线程都想访问它暂用的锁时,都会被导致牵连,导致无法正常运行。直到对应的线程上进行了resume()操作,被挂起的线程才能继续,从而其他所有阻塞在相关锁上的线程也可以继续执行。但是,如果resume()操作意外地在suspend()前就执行了,那么被挂起的线程可能就很难有机会被继续执行。并且,更严重的是:它锁占用的锁不会被释放,因此可能会导致整个操作系统工作不正常。而且,对于被挂起的线程,从它的线程上看状态,居然会是Runnable,这是最气的。

等待线程结束(join)和谦让(yield)

join的方法签名:

public final void join () throws InterruptedException //一直阻塞当前线程,直到目标线程执行完毕
public final synchronized void join (long millis) throws InterruptedException//和之前一样,不过增加了最大等待时间
public static native void yield();

这是一个静态方法,一旦执行,它会使当前线程让出CPU。但要注意,让出CPU并不表示当前线程不执行了。当前线程在让出CPU以后,还会进行CPU资源争夺,但是是否能够再次分配到就要看人品了。

如果你觉得一个线程不那么重要,或者优先级非常低,而且又害怕它会占用太多的CPU资源,那么可以在适当的时候调用Thread.yield(),给予其他重要线程更多的工作机会。

关键字volatile

其作用是防止CPU指令重排和使线程对一个对象的修改令其他线程可见。

对于Java的内存模型来说,每个volatile会在线程的工作内存从保留一个拷贝,只不过java内存模型通过对volatile变量的添加了特殊机制保证了变量的可见性。线程在修改volatile类型变量以后必须立即保存到主内存,在使用变量前必须从主内存加载数据,同时还做了一些禁止指令重排序的操作。对于各个线程的工作内存(私有内存)来说,存在volatile变量不一致的时刻,但是对于执行引擎来说,通过了上面的几条规则保证了变量是一致的。

可参考: Java并发编程之volatile关键字解析

线程安全的概念与synchronized

并行程序开发的一大关注重点就是线程安全。一般来说,程序并行化就是为了获得更高的执行效率,但前提是,不能以牺牲正确性为代价。如果程序并行化以后,连基本的执行结果都无法保证,那么并行程序本身也就没有任何意义了。

volatile并不能真正的保障线程安全。它只能确保一个线程修改了数据后,其他线程能够看到这个改动。但当两个线程同时修改某一个数据时,却依然会产生冲突。

关键字synchronized的作用是实现线程间的同步。它的工作是对同步的代码加锁,使得每一次,只能有一个线程进入同步块,从而保证线程间的安全。

关键字synchronized可以有多种用法:

  • 指定加锁对象:对给定对象加锁,进入同步代码前要获得给定对象的锁。
  • 直接作用于实例方法:相当于对当前实例加锁,进入同步代码前要获得当前实例的锁。
  • 直接作用于静态方法:相当于对当前类加锁,进入同步代码前要获得当前类的锁。
阅读 1.7k

推荐阅读
泊浮说
用户专栏

作者是个热爱分享交流的人,所以有了这个专栏。你的点赞是我最大的更新动力。

56 人关注
45 篇文章
专栏主页
目录