侧边栏壁纸
博主头像
qingtian博主等级

喜欢是一件细水流长的事,是永不疲惫的双向奔赴~!

  • 累计撰写 104 篇文章
  • 累计创建 48 个标签
  • 累计收到 1 条评论

Java并发编程

qingtian
2020-08-15 / 0 评论 / 0 点赞 / 1,003 阅读 / 72,267 字 / 正在检测是否收录...
温馨提示:
本文最后更新于 2020-11-09,若内容或图片失效,请留言反馈。部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

Java并发编程

并发与并行

什么是并发

并发(Concurrent),在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行。

那么,操作系统视如何实现这种并发的呢?

现在我们用到操作系统,无论是Windows、Linux还是MacOS等其实都是多用户多任务分时操作系统。使用这些操作系统的用户是可以“同时”干多件事的。

但是实际上,对于单CPU的计算机来说,在CPU中,同一时间是只能干一件事儿的。为了看起来像是“同时干多件事”,分时操作系统是把CPU的时间划分成长短基本相同的时间区间,即”时间片”,通过操作系统的管理,把这些时间片依次轮流地分配给各个用户使用。

如果某个作业在时间片结束之前,整个任务还没有完成,那么该作业就被暂停下来,放弃CPU,等待下一轮循环再继续做.此时CPU又分配给另一个作业去使用。

由于计算机的处理速度很快,只要时间片的间隔取得适当,那么一个用户作业从用完分配给它的一个时间片到获得下一个CPU时间片,中间有所”停顿”,但用户察觉不出来,好像整个系统全由它”独占”似的。

所以,在单CPU的计算机中,我们看起来“同时干多件事”,其实是通过CPU时间片技术,并发完成的。

==同一时间段,多个任务都在执行 (单位时间内不一定同时执行);==

什么是并行

并行(Parallel),当系统有一个以上CPU时,当一个CPU执行一个进程时,另一个CPU可以执行另一个进程,两个进程互不抢占CPU资源,可以同时进行,这种方式我们称之为并行(Parallel)。

==单位时间内,多个任务同时执行。==

并发与并行的区别

Erlang 之父 Joe Armstrong 用一张比较形象的图解释了并发与并行的区别:

_CON

并发是两个队伍交替使用一台咖啡机。并行是两个队伍同时使用两台咖啡机。

映射到计算机系统中,上图中的咖啡机就是CPU,两个队伍指的就是两个进程。

线程

线程与进程的区别

何为进程?

进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。

在 Java 中,当我们启动 main 函数时其实就是启动了一个 JVM 的进程,而 main 函数所在的线程就是这个进程中的一个线程,也称主线程。

如下图所示,在 windows 中通过查看任务管理器的方式,我们就可以清楚看到 window 当前运行的进程(.exe 文件的运行)。

68747470733a2f2f6d792d626c6f672d746f2d7573652e6f73732d636e2d6265696a696e672e616c6979756e63732e636f6d2f323031392d362f2545382542462539422545372541382538422545372541342542412545342542452538422545352539422542452545372

何为线程

线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享进程的方法区资源,但每个线程有自己的程序计数器虚拟机栈本地方法栈,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。

Java 程序天生就是多线程程序,我们可以通过 JMX 来看一下一个普通的 Java 程序有哪些线程,代码如下。

public class MultiThread {
	public static void main(String[] args) {
		// 获取 Java 线程管理 MXBean
	ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
		// 不需要获取同步的 monitor 和 synchronizer 信息,仅获取线程和线程堆栈信息
		ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(false, false);
		// 遍历线程信息,仅打印线程 ID 和线程名称信息
		for (ThreadInfo threadInfo : threadInfos) {
			System.out.println("[" + threadInfo.getThreadId() + "] " + threadInfo.getThreadName());
		}
	}
}

上述程序输出如下(输出内容可能不同,不用太纠结下面每个线程的作用,只用知道 main 线程执行 main 方法即可):

[5] Attach Listener //添加事件
[4] Signal Dispatcher // 分发处理给 JVM 信号的线程
[3] Finalizer //调用对象 finalize 方法的线程
[2] Reference Handler //清除 reference 线程
[1] main //main 线程,程序入口

从上面的输出内容可以看出:一个 Java 程序的运行是 main 线程和多个其他线程同时运行

JVM角度进程与线程

下图是 Java 内存区域,通过下图我们从 JVM 的角度来说一下线程和进程之间的关系。如果你对 Java 内存区域 (运行时数据区) 这部分知识不太了解的话可以阅读一下这篇文章:《可能是把 Java 内存区域讲的最清楚的一篇文章》

68747470733a2f2f6d792d626c6f672d746f2d7573652e6f73732d636e2d6265696a696e672e616c6979756e63732e636f6d2f323031392d332f4a564de8bf90e8a18ce697b6e695b0e68daee58cbae59f9f2e706e67

从上图可以看出:一个进程中可以有多个线程,多个线程共享进程的和**方法区 (JDK1.8 之后的元空间)*资源,但是每个线程有自己的*程序计数器虚拟机栈本地方法栈

总结: 线程 是 进程 划分成的更小的运行单位。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护;而进程正相反

下面是该知识点的扩展内容!

下面来思考这样一个问题:为什么程序计数器虚拟机栈本地方法栈是线程私有的呢?为什么堆和方法区是线程共享的呢?

程序计数器为什么是私有的?

程序计数器主要有下面两个作用:

  1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

需要注意的是,如果执行的是 native 方法,那么程序计数器记录的是 undefined 地址,只有执行的是 Java 代码时程序计数器记录的才是下一条指令的地址。

所以,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置

虚拟机栈和本地方法栈为什么是私有的?
  • 虚拟机栈: 每个 Java 方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
  • 本地方法栈: 和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

所以,为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。

一句话简单了解堆和方法区

堆和方法区是所有线程共享的资源,其中堆是进程中最大的一块内存,主要用于存放新创建的对象 (所有对象都在这里分配内存),方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

线程的实现

线程的状态

Java线程在运行的生命周期中可能处于表4-1所示的6种不同的状态,在给定的一个时刻,线程只能处于其中的一个状态。

2020-08-12_114528

新建(new)

创建后尚未启动。

可运行(RUNABLE)

正在 Java 虚拟机中运行。但是在操作系统层面,它可能处于运行状态,也可能等待资源调度(例如处理器资源),资源调度完成就进入运行状态。所以该状态的可运行是指可以被运行,具体有没有运行要看底层操作系统的资源调度。

阻塞(BLOCKED)

请求获取 monitor lock 从而进入 synchronized 函数或者代码块,但是其它线程已经占用了该 monitor lock,所以出于阻塞状态。要结束该状态进入从而 RUNABLE 需要其他线程释放 monitor lock。

无限期等待(WAITING)

等待其它线程显式地唤醒。

阻塞和等待的区别在于,阻塞是被动的,它是在等待获取 monitor lock。而等待是主动的,通过调用 Object.wait() 等方法进入。

进入方法退出方法
没有设置 Timeout 参数的 Object.wait() 方法Object.notify() / Object.notifyAll()
没有设置 Timeout 参数的 Thread.join() 方法被调用的线程执行完毕
LockSupport.park() 方法LockSupport.unpark(Thread)

限期等待(TIMED_WAITING)

无需等待其它线程显式地唤醒,在一定时间之后会被系统自动唤醒。

进入方法退出方法
Thread.sleep() 方法时间结束
设置了 Timeout 参数的 Object.wait() 方法时间结束 / Object.notify() / Object.notifyAll()
设置了 Timeout 参数的 Thread.join() 方法时间结束 / 被调用的线程执行完毕
LockSupport.parkNanos() 方法LockSupport.unpark(Thread)
LockSupport.parkUntil() 方法LockSupport.unpark(Thread)

调用 Thread.sleep() 方法使线程进入限期等待状态时,常常用“使一个线程睡眠”进行描述。调用 Object.wait() 方法使线程进入限期等待或者无限期等待时,常常用“挂起一个线程”进行描述。睡眠和挂起是用来描述行为,而阻塞和等待用来描述状态。

死亡(TERMINATED)

可以是线程结束任务之后自己结束,或者产生了异常而结束。

线程调度

2020-08-12_161636

由图4-1中可以看到,线程创建之后,调用start()方法开始运行。当线程执行wait()方法之后,线程进入等待状态。进入等待状态的线程需要依靠其他线程的通知才能够返回到运行状 态,而超时等待状态相当于在等待状态的基础上增加了超时限制,也就是超时时间到达时将 会返回到运行状态。当线程调用同步方法时,在没有获取到锁的情况下,线程将会进入到阻塞 状态。线程在执行Runnable的run()方法之后将会进入到终止状态。

注意: Java将操作系统中的运行和就绪两个状态合并称为运行状态。阻塞状态是线程
阻塞在进入synchronized关键字修饰的方法或代码块(获取锁)时的状态,但是阻塞在 java.concurrent包中Lock接口的线程状态却是等待状态,因为java.concurrent包中Lock接口对于 阻塞的实现均使用了LockSupport类中的相关方法。

线程的优先级

现代操作系统基本采用时分的形式调度运行的线程,操作系统会分出一个个时间片,线程会分配到若干时间片,当线程的时间片用完了就会发生线程调度,并等待着下次分配。线程 分配到的时间片多少也就决定了线程使用处理器资源的多少,而线程优先级就是决定线程需 要多或者少分配一些处理器资源的线程属性。

在Java线程中,通过一个整型成员变量priority来控制优先级,优先级的范围从1~10,在线程构建的时候可以通过setPriority(int)方法来修改优先级,默认优先级是5,优先级高的线程分 配时间片的数量要多于优先级低的线程。设置线程优先级时,针对频繁阻塞(休眠或者I/O操 作)的线程需要设置较高优先级,而偏重计算(需要较多CPU时间或者偏运算)的线程则设置较 低的优先级,确保处理器不会被独占。

public class Priority {

    private static volatile boolean notStart = true;

    private static volatile boolean notEnd = true;
    public static void main(String[] args) throws InterruptedException {
        List<Job> jobList = new ArrayList<Job>();
        for (int i = 0; i < 10; i++) {
            int priority = i < 5? Thread.MIN_PRIORITY:Thread.MAX_PRIORITY;
            Job job = new Job(priority);
            jobList.add(job);
            Thread thread = new Thread(job, "Thread:" + i);
            thread.setPriority(priority);
            thread.start();
        }
        notStart = false;
        TimeUnit.SECONDS.sleep(10);
        notEnd = false;
        for (Job job : jobList) {
            System.out.println("Job Priority : " + job.priority + ", Count : " + job.jobCount);
        }
    }

    static class Job implements Runnable {
        private int priority;
        private long jobCount;

        public Job(int priority) {
            this.priority = priority;
        }

        @Override
        public void run() {

            while (notStart) {
                Thread.yield();
            }
            while (notEnd) {
                Thread.yield();
                jobCount++;
            }
        }
    }
}

打印结果:(Windows10)jdk1.8

Job Priority : 1, Count : 81146
Job Priority : 1, Count : 81158
Job Priority : 1, Count : 81142
Job Priority : 1, Count : 81164
Job Priority : 1, Count : 81155
Job Priority : 10, Count : 2581866
Job Priority : 10, Count : 2582385
Job Priority : 10, Count : 2454852
Job Priority : 10, Count : 2580806
Job Priority : 10, Count : 2581897

==注意== 线程优先级不能作为程序正确性的依赖,因为操作系统可以完全不用理会Java线程对于优先级的设定。笔者的环境为:Mac OS X 10.10,Java版本为1.7.0_71,经过笔者验证 该环境下所有Java线程优先级均为5(通过jstack查看),对线程优先级的设置会被忽略。另外, 尝试在Ubuntu 14.04环境下运行该示例,输出结果也表示该环境忽略了线程优先级的设置。(Java并发编程的艺术)。

守护线程(Daemon线程)

Daemon线程是一种支持型线程,因为它主要被用作程序中后台调度以及支持性工作。这意味着,当一个Java虚拟机中不存在非Daemon线程的时候,Java虚拟机将会退出。可以通过调 用Thread.setDaemon(true)将线程设置为Daemon线程。

Daemon属性需要在启动线程之前设置,不能在启动线程之后设置。

Java程序入口就是由JVM启动main线程,main线程又可以启动其他线程。当所有线程都运行结束时,JVM退出,进程结束。

如果有一个线程没有退出,JVM进程就不会退出。所以,必须保证所有线程都能及时结束。

但是有一种线程的目的就是无限循环,例如,一个定时触发任务的线程:

class TimerThread extends Thread {
    @Override
    public void run() {
        while (true) {
            System.out.println(LocalTime.now());
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                break;
            }
        }
    }
}

如果这个线程不结束,JVM进程就无法结束。问题是,由谁负责结束这个线程?

然而这类线程经常没有负责人来负责结束它们。但是,当其他线程结束时,JVM进程又必须要结束,怎么办?

答案是使用守护线程(Daemon Thread)。

守护线程是指为其他线程服务的线程。在JVM中,所有非守护线程都执行完毕后,无论有没有守护线程,虚拟机都会自动退出。

因此,JVM退出时,不必关心守护线程是否已结束。

如何创建守护线程呢?方法和普通线程一样,只是在调用start()方法前,调用setDaemon(true)把该线程标记为守护线程:

Thread t = new MyThread();
t.setDaemon(true);
t.start();

在守护线程中,编写代码要注意:守护线程不能持有任何需要关闭的资源,例如打开文件等,因为虚拟机退出时,守护线程没有任何机会来关闭文件,这会导致数据丢失。

public class Daemon {
    public static void main(String[] args) {

        Thread thread = new Thread(new DaemonRunner(), "DaemonRunner");
        thread.setDaemon(true);
        thread.start();

    }
    static class DaemonRunner implements Runnable {

        @Override
        public void run() {
            try {
                TimeUnit.SECONDS.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

运行Daemon程序,可以看到在终端或者命令提示符上没有任何输出。main线程(非Daemon线程)在启动了线程DaemonRunner之后随着main方法执行完毕而终止,而此时Java虚拟 机中已经没有非Daemon线程,虚拟机需要退出。==Java虚拟机中的所有Daemon线程都需要立即 终止,因此DaemonRunner立即终止,但是DaemonRunner中的finally块并没有执行。==

创建线程的多种方式

继承Thread类创建线程

同样也是需要实现 run() 方法,因为 Thread 类也实现了 Runable 接口。

当调用 start() 方法启动一个线程时,虚拟机会将该线程放入就绪队列中等待被调度,当一个线程被调度时会执行该线程的 run() 方法。

public static void main(String[] args) {

    MyThread myThread = new MyThread();
    myThread.start();
}

static class MyThread extends Thread{
    @Override
    public void run() {
        super.run();
        System.out.println("MyThread已经创建.....");
    }
}

实现Runnable接口创建线程

public static void main(String[] args) {

        Thread myThread = new Thread(new MyThread());
        myThread.start();
    }


    static class MyThread implements Runnable {

        @Override
        public void run() {
            System.out.println("MyThread已经创建.....");
        }
    }

使用lambda表达式

public static void main(String[] args) {

    Thread myThread = new Thread(new Runnable() {
        @Override
        public void run() {
            System.out.println("MyThread已经创建");
        }
    });
    myThread.start();
}

通过Callable和FutureTask创建线程

public static void main(String[] args) throws ExecutionException, InterruptedException {

        MyThread myThread = new MyThread();
        FutureTask futureTask = new FutureTask<>(myThread);
        Thread thread = new Thread(futureTask);
        thread.start();
        while (!futureTask.isDone()) {
            System.out.println("*****wait");
        }
        System.out.println(futureTask.get());

        
        static class MyThread implements Callable{

            @Override
            public Integer call() throws Exception {

                int sum = 0;
                for (int i = 0; i <= 100; i++) {
                    sum += i;
                }

                //TimeUnit.SECONDS.sleep(10);
                System.out.println("MyThread已经创建.....");
                return sum;
            }
        }

继承Thread VS 实现接口

实现接口会更好一些,因为:

  • Java 不支持多重继承,因此继承了 Thread 类就无法继承其它类,但是可以实现多个接口;
  • 类可能只要求可执行就行,继承整个 Thread 类开销过大。

通过线程池创建线程

为什么使用线程池

线程池提供了一种限制和管理资源(包括执行一个任务)。 每个线程池还维护一些基本统计信息,例如已完成任务的数量。

这里借用《Java并发编程的艺术》提到的来说一下使用线程池的好处:

  • 降低资源消耗。 通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
  • 提高响应速度。 当任务到达时,任务可以不需要的等到线程创建就能立即执行。
  • 提高线程的可管理性。 线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
public static void main(String[] args) throws ExecutionException, InterruptedException {
  
        //使用guava框架快速设置线程工厂
        ThreadFactory threadFactory = new ThreadFactoryBuilder().setNameFormat("createThread-pool-%d").build();

        ThreadPoolExecutor executor = new ThreadPoolExecutor(10, 100, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>(1024), threadFactory, new ThreadPoolExecutor.DiscardPolicy());

        executor.execute(new Runnable() {
            @Override
            public void run() {
                int sum = 0;
                for (int i = 0; i < 100; i++) {
                    sum += i;
                }
                System.out.println(sum);
            }
        });

    }

使用lambda表达式

public static void main(String[] args) throws ExecutionException, InterruptedException {

        //使用guava框架快速设置线程工厂
        ThreadFactory threadFactory = new ThreadFactoryBuilder().setNameFormat("createThread-pool-%d").build();

        ThreadPoolExecutor executor = new ThreadPoolExecutor(10, 100, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>(1024), threadFactory, new ThreadPoolExecutor.DiscardPolicy());

        executor.execute(() -> {
            int sum = 0;
            for (int i = 0; i < 100; i++) {
                sum += i;
            }
            System.out.println(sum);
        });

    }

线程池

自己设计线程池

public interface MyIThreadPool {

    //执行任务 在执行任务时请确保任务添加完毕;
    public void execute();

    //添加批量任务
    public void addTask(List<Runnable> tasks);

    public void addTask(Runnable task);

    public void addTask(Runnable[] tasks);

    //中途添加任务 当线程池开启时可以用该方法添加一般不介意使用
    // 个人觉得线程池可以根据线程大小 维护 多个任务队列
    //每个线程池取模 到对应任务队列中获取;当人本线程中没有提供此实现
    public void addSafeTask(List<Runnable> tasks);


    //销毁线程池 建议使用 销毁前会等待任务完成
    public void destory();

    //暴露销毁 不建议使用 可能任务没完成就直接退出
    public void froceDestory();
}
public class MyIThreadPoolImp implements MyIThreadPool {


    //任务队列
    private List<Runnable> taskqueue = new LinkedList<Runnable>();
    //默认开启线程数量
    private static final int defaultNums = 5;
    //线程数
    private int threadNums;
    //需设置为volatile型,当赋值变化时线程能感知
    //private volatile boolean isRunning = true;
    //线程数 不会变 不需要维持线程安全
    TaskThread[] taskThreads;

    private MyIThreadPoolImp() {
        this(5);
    }

    private MyIThreadPoolImp(int num) {
        if (num <= 0) {
            threadNums = defaultNums;
        } else {
            threadNums = num;
        }

        taskThreads = new TaskThread[threadNums];
        for (int i = 0; i < threadNums; i++) {
            TaskThread thread = new TaskThread();
            thread.setName("TaskThread--"+i);
            taskThreads[i] = thread;
        }
    }

    public static MyIThreadPoolImp getMyIThreadPool(int num) {
        return new MyIThreadPoolImp(num);
    }

    //执行任务
    @Override
    public void execute() {

        for (int i = 0; i < threadNums; i++) {
            System.out.println(taskThreads[i].getName()+"开启了");
            taskThreads[i].start();
        }
    }

    @Override
    public void addTask(List<Runnable> tasks) {

        taskqueue.addAll(tasks);
    }

    @Override
    public void addTask(Runnable task) {

        taskqueue.add(task);
    }

    @Override
    public void addTask(Runnable[] tasks) {

        for (Runnable task : tasks) {
            taskqueue.add(task);
        }
    }

    @Override
    public void addSafeTask(List<Runnable> tasks) {

        synchronized (taskqueue) {
            taskqueue.addAll(tasks);
        }
    }

    @Override
    public void destory() {

        while (!taskqueue.isEmpty()) {
            try {
                Thread.sleep(20);
            } catch (InterruptedException e) {
                System.out.println("请等待任务执行完成");
                e.printStackTrace();
            }
        }
        //isRunning = false;
        for (int i = 0; i < threadNums; i++) {
            taskThreads[i].cancel();
            taskThreads[i] = null;
        }
    }

    @Override
    public void froceDestory() {

        for (int i = 0; i < threadNums; i++) {
            taskThreads[i].cancel();
            taskThreads[i] = null;
        }
    }

    class TaskThread extends Thread {

        private volatile boolean on = true;

        Runnable runnable = null;

        @Override
        public void run() {
            //当线程池可用 无限循环
            while (on) {
                synchronized (taskqueue) {
                    if (!taskqueue.isEmpty() && on) {
                        runnable = taskqueue.remove(0);
                    } else {
                        try {
                            //等待20后释放锁锁
                            taskqueue.wait(20);
                        } catch (Exception e) {
                            e.printStackTrace();
                        }
                    }
                }
                //记住将runnable置于同步代码块外,否则会导致一个线程占有锁时间过长
                if (runnable != null) {
                    runnable.run();
                }
            }
        }
        public void cancel(){
            on = false;
        }

    }
}
public class MyThreadPoolTest {

    public static  void main(String[] args){
        MyIThreadPool myIThreadPool = MyIThreadPoolImp.getMyIThreadPool(7);
        for(int i =0 ;i<50;i++){
            myIThreadPool.addTask(new MyTask());
        }
        myIThreadPool.execute();
        myIThreadPool.destory();

    }

    static   class MyTask implements Runnable{

        @Override
        public void run() {
            System.out.println(Thread.currentThread().getName()+"处理了");
        }
    }
}

submit() 和 execute()

可以使用两个方法向线程池提交任务,分别为execute()和submit()方法。

execute()方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功。通过以下代码可知execute()方法输入的任务是一个Runnable类的实例。

submit()方法用于提交需要返回值的任务。线程池会返回一个future类型的对象,通过这个future对象可以判断任务是否执行成功,并且可以通过future的get()方法来获取返回值,get()方 法会阻塞当前线程直到任务完成,而使用get(long timeout,TimeUnit unit)方法则会阻塞当前线 程一段时间后立即返回,这时候有可能任务没有执行完。

线程池原理

当向线程池提交一个任务之后,线程池是如何处理这个任务的呢?本节来看一下线程池的主要处理流程,处理流程图如图9-1所示。

从图中可以看出,当提交一个新任务到线程池时,线程池的处理流程如下。

  • 1)线程池判断核心线程池里的线程是否都在执行任务。如果不是,则创建一个新的工作
    线程来执行任务。如果核心线程池里的线程都在执行任务,则进入下个流程。

  • 2)线程池判断工作队列是否已经满。如果工作队列没有满,则将新提交的任务存储在这
    个工作队列里。如果工作队列满了,则进入下个流程。

  • 3)线程池判断线程池的线程是否都处于工作状态。如果没有,则创建一个新的工作线程来执行任务。如果已经满了,则交给饱和策略来处理这个任务。

ThreadPoolExecutor执行execute()方法的示意图,如图9-2所示。

2020-08-13_134521

2020-08-13_134620

ThreadPoolExecutor执行execute方法分下面4种情况。

  • 1)如果当前运行的线程少于corePoolSize,则创建新线程来执行任务(注意,执行这一步骤需要获取全局锁)。
  • 2)如果运行的线程等于或多于corePoolSize,则将任务加入BlockingQueue。
  • 3)如果无法将任务加入BlockingQueue(队列已满),则创建新的线程来处理任务(注意,执行这一步骤需要获取全局锁)。
  • 4)如果创建新线程将使当前运行的线程超maximumPoolSize,任务将被拒绝,并调用RejectedExecutionHandler.rejectedExecution()方法。

2020-08-13_152643

线程池中的线程执行任务分两种情况,如下。

  • 1)在execute()方法中创建一个线程时,会让这个线程执行当前任务。
  • 2)这个线程执行完上图中1的任务后,会反复从BlockingQueue获取任务来执行。

线程池的几个参数

  1. corePoolSize(线程池的基本大小):当提交一个任务到线程池时,线程池会创建一个线程来执行任务,即使其他空闲的基本线程能够执行新任务也会创建线程,等到需要执行的任 务数大于线程池基本大小时就不再创建。如果调用了线程池的prestartAllCoreThreads()方法, 线程池会提前创建并启动所有基本线程。
  2. runnableTaskQueue(任务队列):用于保存等待执行的任务的阻塞队列。可以选择以下几个阻塞队列。
    • ArrayBlockingQueue:是一个基于数组结构的有界阻塞队列,此队列按FIFO(先进先出)原
      则对元素进行排序。
    • LinkedBlockingQueue:一个基于链表结构的阻塞队列,此队列按FIFO排序元素,吞吐量通常要高于ArrayBlockingQueue。静态工厂方法Executors.newFixedThreadPool()使用了这个队列。
    • SynchronousQueue:一个不存储元素的阻塞队列。每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于Linked-BlockingQueue,静态工 厂方法Executors.newCachedThreadPool使用了这个队列。
    • PriorityBlockingQueue:一个具有优先级的无限阻塞队列。
  3. maximumPoolSize(线程池最大数量):线程池允许创建的最大线程数。如果队列满了,并且已创建的线程数小于最大线程数,则线程池会再创建新的线程执行任务。值得注意的是,如 果使用了无界的任务队列这个参数就没什么效果。
  4. ThreadFactory:用于设置创建线程的工厂,可以通过线程工厂给每个创建出来的线程设置更有意义的名字。使用开源框架guava提供的ThreadFactoryBuilder可以快速给线程池里的线 程设置有意义的名字
  5. RejectedExecutionHandler(饱和策略):当队列和线程池都满了,说明线程池处于饱和状态,那么必须采取一种策略处理提交的新任务。这个策略默认情况下是AbortPolicy,表示无法 处理新任务时抛出异常。在JDK 1.5中Java线程池框架提供了以下4种策略。
    • AbortPolicy:直接抛出异常。
    • CallerRunsPolicy:只用调用者所在线程来运行任务。
    • DiscardOldestPolicy:丢弃队列里最近的一个任务,并执行当前任务。
    • DiscardPolicy:不处理,丢弃掉。
  6. keepAliveTime(线程活动保持时间):线程池的工作线程空闲后,保持存活的时间。所以,如果任务很多,并且每个任务执行的时间比较短,可以调大时间,提高线程的利用率。

为什么不允许使用Executors创建线程池

在《阿里巴巴开发手册》中明确指出:不允许使用Executors创建线程池。

图像

线程安全

什么是线程安全

多个线程不管以何种方式访问某个类,并且在主调代码中不需要进行同步,都能表现正确的行为。

多级缓存和一致性问题

我们应该都知道,计算机在执行程序的时候,每条指令都是在 CPU 中执行的,而执行的时候,又免不了要和数据打交道。而计算机上面的数据,是存放在主存当中的,也就是计算机的物理内存啦。

刚开始,还相安无事的,但是随着 CPU 技术的发展,CPU 的执行速度越来越快。而由于内存的技术并没有太大的变化,所以从内存中读取和写入数据的过程和 CPU 的执行速度比起来差距就会越来越大, 这就导致 CPU 每次操作内存都要耗费很多等待时间。

这就像一家创业公司,刚开始,创始人和员工之间工作关系其乐融融,但是随着创始人的能力和野心越来越大,逐渐和员工之间出现了差距,普通员工原来越跟不上 CEO 的脚步。老板的每一个命令,传到到基层员工之后,由于基层员工的理解能力、执行能力的欠缺,就会耗费很多时间。这也就无形中拖慢了整家公司的工作效率。

可是,不能因为内存的读写速度慢,就不发展 CPU 技术了吧,总不能让内存成为计算机处理的瓶颈吧。

所以,人们想出来了一个好的办法,就是在 CPU 和内存之间增加高速缓存。缓存的概念大家都知道,就是保存一份数据拷贝。他的特点是速度快,内存小,并且昂贵。

那么,程序的执行过程就变成了:

当程序在运行过程中,会将运算需要的数据从主存复制一份到 CPU 的高速缓存当中,那么 CPU 进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。

之后,这家公司开始设立中层管理人员,管理人员直接归 CEO 领导,领导有什么指示,直接告诉管理人员,然后就可以去做自己的事情了。管理人员负责去协调底层员工的工作。因为管理人员是了解手下的人员以及自己负责的事情的。所以,大多数时候,公司的各种决策,通知等,CEO 只要和管理人员之间沟通就够了。

而随着 CPU 能力的不断提升,一层缓存就慢慢的无法满足要求了,就逐渐的衍生出多级缓存。

按照数据读取顺序和与 CPU 结合的紧密程度,CPU 缓存可以分为一级缓存(L1),二级缓存(L3),部分高端 CPU 还具有三级缓存(L3),每一级缓存中所储存的全部数据都是下一级缓存的一部分。

这三种缓存的技术难度和制造成本是相对递减的,所以其容量也是相对递增的。

那么,在有了多级缓存之后,程序的执行就变成了:

当 CPU 要读取一个数据时,首先从一级缓存中查找,如果没有找到再从二级缓存中查找,如果还是没有就从三级缓存或内存中查找。

随着公司越来越大,老板要管的事情越来越多,公司的管理部门开始改革,开始出现高层,中层,底层等管理者。一级一级之间逐层管理。

单核 CPU 只含有一套 L1,L2,L3 缓存;如果 CPU 含有多个核心,即多核 CPU,则每个核心都含有一套 L1(甚至和 L2)缓存,而共享 L3(或者和 L2)缓存。

公司也分很多种,有些公司只有一个大 Boss,他一个人说了算。但是有些公司有比如联席总经理、合伙人等机制。

单核 CPU 就像一家公司只有一个老板,所有命令都来自于他,那么就只需要一套管理班底就够了。

多核 CPU 就像一家公司是由多个合伙人共同创办的,那么,就需要给每个合伙人都设立一套供自己直接领导的高层管理人员,多个合伙人共享使用的是公司的底层员工。

还有的公司,不断壮大,开始差分出各个子公司。各个子公司就是多个 CPU 了,互相之前没有共用的资源。互不影响。

下图为一个单 CPU 双核的缓存结构。

CACHE

随着计算机能力不断提升,开始支持多线程。那么问题就来了。我们分别来分析下单线程、多线程在单核CPU、多核CPU中的影响。

**单线程。**cpu核心的缓存只被一个线程访问。缓存独占,不会出现访问冲突等问题。

**单核CPU,多线程。**进程中的多个线程会同时访问进程中的共享数据,CPU将某块内存加载到缓存后,不同线程在访问相同的物理地址的时候,都会映射到相同的缓存位置,这样即使发生线程的切换,缓存仍然不会失效。但由于任何时刻只能有一个线程在执行,因此不会出现缓存访问冲突。

**多核CPU,多线程。**每个核都至少有一个L1 缓存。多个线程访问进程中的某个共享内存,且这多个线程分别在不同的核心上执行,则每个核心都会在各自的cache中保留一份共享内存的缓冲。由于多核是可以并行的,可能会出现多个线程同时写各自的缓存的情况,而各自的cache之间的数据就有可能不同。

在CPU和主存之间增加缓存,在多线程场景下就可能存在缓存一致性问题,也就是说,在多核CPU中,每个核的自己的缓存中,关于同一个数据的缓存内容可能不一致。

如果这家公司的命令都是串行下发的话,那么就没有任何问题。

如果这家公司的命令都是并行下发的话,并且这些命令都是由同一个CEO下发的,这种机制是也没有什么问题。因为他的命令执行者只有一套管理体系。

如果这家公司的命令都是并行下发的话,并且这些命令是由多个合伙人下发的,这就有问题了。因为每个合伙人只会把命令下达给自己直属的管理人员,而多个管理人员管理的底层员工可能是公用的。

比如,合伙人1要辞退员工a,合伙人2要给员工a升职,升职后的话他再被辞退需要多个合伙人开会决议。两个合伙人分别把命令下发给了自己的管理人员。合伙人1命令下达后,管理人员a在辞退了员工后,他就知道这个员工被开除了。而合伙人2的管理人员2这时候在没得到消息之前,还认为员工a是在职的,他就欣然的接收了合伙人给他的升职a的命令。

一致

CPU时间片和原子性问题

上面提到在在 CPU 和主存之间增加缓存,在多线程场景下会存在缓存一致性问题。除了这种情况,还有一种硬件问题也比较重要。那就是为了使处理器内部的运算单元能够尽量的被充分利用,处理器可能会对输入代码进行乱序执行处理。这就是处理器优化

为了充分利用运算单元,可以将一条指令分为5,6步,交给几个运算单元计算,这样可以在一个cpu时间片完成一条指令。

指令集并行的重排序

处理器优化其实上是指的指令集并行的重排序,

指令集并行的重排序是对CPU的性能优化,从指令的执行角度来说一条指令可以分为多个步骤完成,如下:

  • 取指 IF
  • 译码和取寄存器操作数 ID
  • 执行或者有效地址计算 EX
  • 存储器访问 MEM
  • 写回 WB

CPU在工作时,需要将上述指令分为多个步骤依次执行(注意硬件不同有可能不一样),由于每一个步会使用到不同的硬件操作,比如取指时会只有PC寄存器和存储器,译码时会执行到指令寄存器组,执行时会执行ALU(算术逻辑单元)、写回时使用到寄存器组。为了提高硬件利用率,CPU指令是按流水线技术来执行的,如下:

1597320400441-6b978c2f-6c0e-4626-9075-d6fc1d13786b

从图中可以看出当指令1还未执行完成时,第2条指令便利用空闲的硬件开始执行,这样做是有好处的,如果每个步骤花费1ms,那么如果第2条指令需要等待第1条指令执行完成后再执行的话,则需要等待5ms,但如果使用流水线技术的话,指令2只需等待1ms就可以开始执行了,这样就能大大提升CPU的执行性能。虽然流水线技术可以大大提升CPU的性能,但不幸的是一旦出现流水中断,所有硬件设备将会进入一轮停顿期,当再次弥补中断点可能需要几个周期,这样性能损失也会很大,就好比工厂组装手机的流水线,一旦某个零件组装中断,那么该零件往后的工人都有可能进入一轮或者几轮等待组装零件的过程。因此我们需要尽量阻止指令中断的情况,指令重排就是其中一种优化中断的手段,我们通过一个例子来阐明指令重排是如何阻止流水线技术中断的

a = b + c ;
d = e + f ;

下面通过汇编指令展示了上述代码在CPU执行的处理过程

1597320400805-45aff39e-fbac-4887-8887-63bee0a6c93b

  • LW指令 表示 load,其中LW R1,b表示把b的值加载到寄存器R1中
  • LW R2,c 表示把c的值加载到寄存器R2中
  • ADD 指令表示加法,把R1 、R2的值相加,并存入R3寄存器中。
  • SW 表示 store 即将 R3寄存器的值保持到变量a中
  • LW R4,e 表示把e的值加载到寄存器R4中
  • LW R5,f 表示把f的值加载到寄存器R5中
  • SUB 指令表示减法,把R4 、R5的值相减,并存入R6寄存器中。
  • SW d,R6 表示将R6寄存器的值保持到变量d中

上述便是汇编指令的执行过程,在某些指令上存在X的标志,X代表中断的含义,也就是只要有X的地方就会导致指令流水线技术停顿,同时也会影响后续指令的执行,可能需要经过1个或几个指令周期才可能恢复正常,那为什么停顿呢?这是因为部分数据还没准备好,如执行ADD指令时,需要使用到前面指令的数据R1,R2,而此时R2的MEM操作没有完成,即未拷贝到存储器中,这样加法计算就无法进行,必须等到MEM操作完成后才能执行,也就因此而停顿了,其他指令也是类似的情况。前面阐述过,停顿会造成CPU性能下降,因此我们应该想办法消除这些停顿,这时就需要使用到指令重排了,如下图,既然ADD指令需要等待,那我们就利用等待的时间做些别的事情,如把LW R4,eLW R5,f 移动到前面执行,毕竟LW R4,eLW R5,f执行并没有数据依赖关系,对他们有数据依赖关系的SUB R6,R5,R4指令在R4,R5加载完成后才执行的,没有影响,过程如下:

1597320401404-84854ab8-554c-4e34-b53a-adfbfc29815a

正如上图所示,所有的停顿都完美消除了,指令流水线也无需中断了,这样CPU的性能也能带来很好的提升,这就是处理器指令重排的作用。

我们在Java的并发编程中的多线程问题到底是怎么回事儿?中分析过:线程是CPU调度的基本单位。CPU有时间片的概念,会根据不同的调度算法进行线程调度。当一个线程获得时间片之后开始执行,在时间片耗尽之后,就会失去CPU使用权。所以在多线程场景下,由于时间片在线程间轮换,就会发生原子性问题。

指令重排和有序性问题

除了现在很多流行的处理器会对代码进行优化乱序处理,很多编程语言的编译器也会有类似的优化,比如 Java 虚拟机的即时编译器(JIT)也会做指令重排

有没有发现,缓存一致性问题其实就是可见性问题。而处理器优化是可以导致原子性问题的。指令重排即会导致有序性问题。所以,后文将不再提起硬件层面的那些概念,而是直接使用大家熟悉的原子性、可见性和有序性。

线程安全和内存模型的关系

什么是内存模型

前面提到的,缓存一致性问题、处理器器优化的指令重排问题是硬件的不断升级导致的。那么,有没有什么机制可以很好的解决上面的这些问题呢?

最简单直接的做法就是废除处理器和处理器的优化技术、废除 CPU 缓存,让 CPU 直接和主存交互。但是,这么做虽然可以保证多线程下的并发问题。但是,这就有点因噎废食了。

所以,为了保证并发编程中可以满足原子性、可见性及有序性。有一个重要的概念,那就是——内存模型。

**为了保证共享内存的正确性(可见性、有序性、原子性),内存模型定义了共享内存系统中多线程程序读写操作行为的规范。**通过这些规则来规范对内存的读写操作,从而保证指令执行的正确性。它与处理器有关、与缓存有关、与并发有关、与编译器也有关。他解决了 CPU 多级缓存、处理器优化、指令重排等导致的内存访问问题,保证了并发场景下的一致性、原子性和有序性。

内存模型解决并发问题主要采用两种方式:限制处理器优化使用内存屏障。本文就不深入底层原理来展开介绍了,感兴趣的朋友可以自行学习。

什么是 Java 内存模型

前面介绍过了计算机内存模型,这是解决多线程场景下并发问题的一个重要规范。那么具体的实现是如何的呢,不同的编程语言,在实现上可能有所不同。

我们知道,Java 程序是需要运行在 Java 虚拟机上面的,Java 内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了 Java 程序在各种平台下对内存的访问都能保证效果一致的机制及规范。

提到 Java 内存模型,一般指的是 JDK 5 开始使用的新的内存模型,主要由 JSR-133: JavaTM Memory Model and Thread Specification 描述。感兴趣的可以参看下这份 PDF 文档(https://www.cs.umd.edu/~pugh/java/memoryModel/jsr133.pdf)

Java 内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。

而 JMM 就作用于工作内存和主存之间数据同步过程。他规定了如何做数据同步以及什么时候做数据同步。

1596550084589-92869040-9854-4814-beea-7eee22d711aa

这里面提到的主内存和工作内存,读者可以简单的类比成计算机内存模型中的主存和缓存的概念。特别需要注意的是,主内存和工作内存与 JVM 内存结构中的 Java 堆、栈、方法区等并不是同一个层次的内存划分,无法直接类比。《深入理解 Java 虚拟机》中认为,如果一定要勉强对应起来的话,从变量、主内存、工作内存的定义来看,主内存主要对应于 Java 堆中的对象实例数据部分。工作内存则对应于虚拟机栈中的部分区域。

所以,再来总结下,JMM 是一种规范,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。

happens-before

happens-before定义

happens-before的概念最初由Leslie Lamport在其一篇影响深远的论文(《Time,Clocks and the Ordering of Events in a Distributed System》)中提出,有兴趣的可以google一下。JSR-133使用happens-before的概念来指定两个操作之间的执行顺序。由于这两个操作可以在一个线程之内,也可以是在不同线程之间。

因此,JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证(如果A线程的写操作a与B线程的读操作b之间存在happens-before关系,尽管a操作和b操作在不同的线程中执行,但JMM向程序员保证a操作将对b操作可见)。

具体的定义为:

1)如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。

2)两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。

上面的(1)是JMM对程序员的承诺。从程序员的角度来说,可以这样理解happens-before关系:如果A happens-before B,那么Java内存模型将向程序员保证——A操作的结果将对B可见,且A的执行顺序排在B之前。注意,这只是Java内存模型向程序员做出的保证!

上面的(2)是JMM对编译器和处理器重排序的约束原则。正如前面所言,JMM其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。JMM这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)。

因此,happens-before关系本质上和as-if-serial语义是一回事。

as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同
步的多线程程序的执行结果不被改变。 ·as-if-serial语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺
序来执行的。happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻境:正 确同步的多线程程序是按happens-before指定的顺序来执行的。
as-if-serial语义和happens-before这么做的目的,都是为了在不改变程序执行结果的前提 下,尽可能地提高程序执行的并行度。

具体规则

具体的一共有八项规则:

**程序顺序规则:**一个线程中的每个操作,happens-before于该线程中的任意后续操作。

**监视器锁规则:**对一个锁的解锁,happens-before于随后对这个锁的加锁。

**volatile变量规则:**对一个volatile域的写,happens-before于任意后续对这个volatile域的读。

**传递性:**如果A happens-before B,且B happens-before C,那么A happens-before C。

**start()规则:**如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。

**join()规则:**如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。

**程序中断规则:**对线程interrupted()方法的调用先行于被中断线程的代码检测到中断时间的发生。

**对象finalize规则:**一个对象的初始化完成(构造函数执行结束)先行于发生它的finalize()方法的开始。

as-if-serial

as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。

为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被 编译器和处理器重排序。

可重入锁

重入锁ReentrantLock,顾名思义,就是支持重进入的锁,它表示该锁能够支持一个线程对资源的重复加锁。除此之外,该锁的还支持获取锁时的公平和非公平性选择。

同时考虑如下场景:当一个线程调用Mutex的lock()方法获取锁之后,如果再次调用lock()方法,则该线程将会被自己所阻塞,原因是Mutex在实现 tryAcquire(int acquires)方法时没有考虑占有锁的线程再次获取锁的场景,而在调用 tryAcquire(int acquires)方法时返回了false,导致该线程被阻塞。简单地说,Mutex是一个不支持 重进入的锁。而synchronized关键字隐式的支持重进入,比如一个synchronized修饰的递归方 法,在方法执行时,执行线程在获取了锁之后仍能连续多次地获得该锁,而不像Mutex由于获 取了锁,而在下一次获取锁时出现阻塞自己的情况。

ReentrantLock虽然没能像synchronized关键字一样支持隐式的重进入,但是在调用lock()方法时,已经获取到锁的线程,能够再次调用lock()方法获取锁而不被阻塞。

重进入是指任意线程在获取到锁之后能够再次获取该锁而不会被锁所阻塞,该特性的实现需要解决以下两个问题。

  • 1)线程再次获取锁。锁需要去识别获取锁的线程是否为当前占据锁的线程,如果是,则再次成功获取。
  • 2)锁的最终释放。线程重复n次获取了锁,随后在第n次释放该锁后,其他线程能够获取到该锁。锁的最终释放要求锁对于获取进行计数自增,计数表示当前锁被重复获取的次数,而锁 被释放时,计数自减,当计数等于0时表示锁已经成功释放。
public class Test implements Runnable {


//    public synchronized void get(){
//        System.out.println(Thread.currentThread().getId());
//        set();
//    }
//
//    public synchronized void set(){
//        System.out.println(Thread.currentThread().getId());
//    }
//
//
//    @Override
//    public void run() {
//        get();
//    }
//
//    public static void main(String[] args) {
//        Test ss=new Test();
//        new Thread(ss).start();
//        new Thread(ss).start();
//        new Thread(ss).start();
//    }


    ReentrantLock lock = new ReentrantLock();

    public void get() {
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getId());
            set();
        }finally {
            lock.unlock();
        }
    }
    public void set() {
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getId());
        }finally {
            lock.unlock();
        }
    }

    @Override
    public void run() {

        get();
    }

    public static void main(String[] args) {
        Test ss=new Test();
        new Thread(ss).start();
        new Thread(ss).start();
        new Thread(ss).start();
    }
}

阻塞锁

阻塞锁,可以说是让线程进入阻塞状态进行等待,当获得相应的信号(唤醒,时间) 时,才可以进入线程的准备就绪状态,准备就绪状态的所有线程,通过竞争,进入运行状态。
JAVA中,能够进入\退出、阻塞状态或包含阻塞锁的方法有 ,synchronized 关键字(其中的重量锁),ReentrantLock,Object.wait()\notify(),LockSupport.park()/unpart()(j.u.c经常使用)

乐观锁与悲观锁

何为悲观锁与乐观锁

乐观锁对应于生活中乐观的人总是想着事情往好的方向发展,悲观锁对应于生活中悲观的人总是想着事情往坏的方向发展。这两种人各有优缺点,不能不以场景而定说一种人好于另外一种人。

悲观锁

总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中synchronizedReentrantLock等独占锁就是悲观锁思想的实现。

乐观锁

总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。

两种锁的使用场景

从上面对两种锁的介绍,我们知道两种锁各有优缺点,不可认为一种好于另一种,像乐观锁适用于写比较少的情况下(多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果是多写的情况,一般会经常产生冲突,这就会导致上层应用会不断的进行retry,这样反倒是降低了性能,所以一般多写的场景下用悲观锁就比较合适。

乐观锁常见的两种实现方式

乐观锁一般会使用版本号机制或CAS算法实现。

1. 版本号机制

一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加一。当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。

举一个简单的例子: 假设数据库中帐户信息表中有一个 version 字段,当前值为 1 ;而当前帐户余额字段( balance )为 $100 。

  1. 操作员 A 此时将其读出( version=1 ),并从其帐户余额中扣除 $50( $100-$50 )。
  2. 在操作员 A 操作的过程中,操作员B 也读入此用户信息( version=1 ),并从其帐户余额中扣除 $20 ( $100-$20 )。
  3. 操作员 A 完成了修改工作,将数据版本号加一( version=2 ),连同帐户扣除后余额( balance=$50 ),提交至数据库更新,此时由于提交数据版本大于数据库记录当前版本,数据被更新,数据库记录 version 更新为 2 。
  4. 操作员 B 完成了操作,也将版本号加一( version=2 )试图向数据库提交数据( balance=$80 ),但此时比对数据库记录版本时发现,操作员 B 提交的数据版本号为 2 ,数据库记录当前版本也为 2 ,不满足 “ 提交版本必须大于记录当前版本才能执行更新 “ 的乐观锁策略,因此,操作员 B 的提交被驳回。

这样,就避免了操作员 B 用基于 version=1 的旧数据修改的结果覆盖操作员A 的操作结果的可能。

2. CAS算法

compare and swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。CAS算法涉及到三个操作数

  • 需要读写的内存值 V
  • 进行比较的值 A
  • 拟写入的新值 B

当且仅当 V 的值等于 A时,CAS通过原子方式用新值B来更新V的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断的重试

关于自旋锁,大家可以看一下这篇文章,非常不错:《 面试必备之深入理解自旋锁》

数据库相关锁机制

MySQL中的行级锁,表级锁,页级锁

MyISAM和InnoDB存储引擎使用的锁:

  • MyISAM采用表级锁(table-level locking)。
  • InnoDB支持行级锁(row-level locking)和表级锁,默认为行级锁

表级锁和行级锁对比:

  • 表级锁: MySQL中锁定 粒度最大 的一种锁,对当前操作的整张表加锁,实现简单,资源消耗也比较少,加锁快,不会出现死锁。其锁定粒度最大,触发锁冲突的概率最高,并发度最低,MyISAM和 InnoDB引擎都支持表级锁。
  • 行级锁: MySQL中锁定 粒度最小 的一种锁,只针对当前操作的行进行加锁。 行级锁能大大减少数据库操作的冲突。其加锁粒度最小,并发度高,但加锁的开销也最大,加锁慢,会出现死锁。
    • Record Lock: 对索引项加锁,锁定符合条件的行。其他事务不能修改和删除加锁项;
    • Gap Lock: 对索引项之间的“间隙”加锁,锁定记录的范围(对第一条记录后的间隙到最后一条记录前的间隙加锁),不包含索引项本身。其他事务不能在锁范围内插入数据,这样就防止了别的事务新增幻影行。
    • Next-key Lock: 锁定索引项本身和索引范围。即Record Lock和Gap Lock的结合。可解决幻读问题。

**页级锁:**页级锁是MySQL中锁定粒度介于行级锁和表级锁中间的一种锁。表级锁速度快,但冲突多,行级冲突少,但速度慢。所以取了折衷的页级,一次锁定相邻的一组记录。BDB支持页级锁

  • 特点:开销和加锁时间界于表锁和行锁之间;会出现死锁;锁定粒度界于表锁和行锁之间,并发度一般

MySQL常用存储引擎的锁机制

MyISAM和MEMORY采用表级锁(table-level locking)

BDB采用页面锁(page-level locking)或表级锁,默认为页面锁

InnoDB支持行级锁(row-level locking)和表级锁,默认为行级锁

Innodb中的行锁与表锁

前面提到过,在Innodb引擎中既支持行锁也支持表锁,那么什么时候会锁住整张表,什么时候或只锁住一行呢?

InnoDB行锁是通过给索引上的索引项加锁来实现的,这一点MySQL与Oracle不同,后者是通过在数据块中对相应数据行加锁来实现的。InnoDB这种行锁实现特点意味着:只有通过索引条件检索数据,InnoDB才使用行级锁,否则,InnoDB将使用表锁!

在实际应用中,要特别注意InnoDB行锁的这一特性,不然的话,可能导致大量的锁冲突,从而影响并发性能。

  • 在不通过索引条件查询的时候,InnoDB 确实使用的是表锁,而不是行锁。
  • 由于 MySQL 的行锁是针对索引加的锁,不是针对记录加的锁,所以虽然是访问不同行 的记录,但是如果是使用相同的索引键,是会出现锁冲突的。应用设计的时候要注意这一点。
  • 当表有多个索引的时候,不同的事务可以使用不同的索引锁定不同的行,另外,不论 是使用主键索引、唯一索引或普通索引,InnoDB 都会使用行锁来对数据加锁。
  • 即便在条件中使用了索引字段,但是否使用索引来检索数据是由 MySQL 通过判断不同 执行计划的代价来决定的,如果 MySQL 认为全表扫 效率更高,比如对一些很小的表,它 就不会使用索引,这种情况下 InnoDB 将使用表锁,而不是行锁。因此,在分析锁冲突时, 别忘了检查 SQL 的执行计划,以确认是否真正使用了索引。

行级锁与死锁

MyISAM中是不会产生死锁的,因为MyISAM总是一次性获得所需的全部锁,要么全部满足,要么全部等待。而在InnoDB中,锁是逐步获得的,就造成了死锁的可能。

在MySQL中,行级锁并不是直接锁记录,而是锁索引。索引分为主键索引和非主键索引两种,如果一条sql语句操作了主键索引,MySQL就会锁定这条主键索引;如果一条语句操作了非主键索引,MySQL会先锁定该非主键索引,再锁定相关的主键索引。 在UPDATE、DELETE操作时,MySQL不仅锁定WHERE条件扫描过的所有索引记录,而且会锁定相邻的键值,即所谓的next-key locking。

当两个事务同时执行,一个锁住了主键索引,在等待其他相关索引。另一个锁定了非主键索引,在等待主键索引。这样就会发生死锁。

发生死锁后,InnoDB一般都可以检测到,并使一个事务释放锁回退,另一个获取锁完成事务。

有多种方法可以避免死锁,这里只介绍常见的三种

1、如果不同程序会并发存取多个表,尽量约定以相同的顺序访问表,可以大大降低死锁机会。

2、在同一个事务中,尽可能做到一次锁定所需要的所有资源,减少死锁产生概率;

3、对于非常容易产生死锁的业务部分,可以尝试使用升级锁定颗粒度,通过表级锁定来减少死锁产生的概率;

分布式锁

在单机场景下,可以使用语言的内置锁来实现进程同步。但是在分布式场景下,需要同步的进程可能位于不同的节点上,那么就需要使用分布式锁。

阻塞锁通常使用互斥量来实现:

  • 互斥量为 0 表示有其它进程在使用锁,此时处于锁定状态;
  • 互斥量为 1 表示未锁定状态。

1 和 0 可以用一个整型值表示,也可以用某个数据是否存在表示。

数据库的唯一索引

获得锁时向表中插入一条记录,释放锁时删除这条记录。唯一索引可以保证该记录只被插入一次,那么就可以用这个记录是否存在来判断是否处于锁定状态。

存在以下几个问题:

  • 锁没有失效时间,解锁失败的话其它进程无法再获得该锁;
  • 只能是非阻塞锁,插入失败直接就报错了,无法重试;
  • 不可重入,已经获得锁的进程也必须重新获取锁。

Redis的set nx ex指令

使用 SETNX(set if not exist)指令插入一个键值对,如果 Key 已经存在,那么会返回 False,否则插入成功并返回 True。

SETNX 指令和数据库的唯一索引类似,保证了只存在一个 Key 的键值对,那么可以用一个 Key 的键值对是否存在来判断是否存于锁定状态。

EXPIRE 指令可以为一个键值对设置一个过期时间,从而避免了数据库唯一索引实现方式中释放锁失败的问题。

Redis的Redlock算法

使用了多个 Redis 实例来实现分布式锁,这是为了保证在发生单点故障时仍然可用。

  • 尝试从 N 个互相独立 Redis 实例获取锁;
  • 计算获取锁消耗的时间,只有时间小于锁的过期时间,并且从大多数(N / 2 + 1)实例上获取了锁,才认为获取锁成功;
  • 如果获取锁失败,就到每个实例上释放锁。

Zookeeper的有序节点

  1. Zookeeper抽象模型:Zookeeper 提供了一种树形结构的命名空间,/app1/p_1 节点的父节点为 /app1。

    aefa8042-15fa-4e8b-9f50-20b282a2c624

  2. 节点类型:

    • 永久节点:不会因为会话结束或者超时而消失;
    • 临时节点:如果会话结束或者超时就会消失;
    • 有序节点:会在节点名的后面加一个数字后缀,并且是有序的,例如生成的有序节点为 /lock/node-0000000000,它的下一个有序节点则为 /lock/node-0000000001,以此类推。
  3. 监听器:为一个节点注册监听器,在节点状态发生改变时,会给客户端发送消息。

  4. 分布式锁实现:

    • 创建一个锁目录 /lock;
    • 当一个客户端需要获取锁时,在 /lock 下创建临时的且有序的子节点;
    • 客户端获取 /lock 下的子节点列表,判断自己创建的子节点是否为当前子节点列表中序号最小的子节点,如果是则认为获得锁;否则监听自己的前一个子节点,获得子节点的变更通知后重复此步骤直至获得锁;
    • 执行业务代码,完成后,删除对应的子节点。
  5. 会话超时:如果一个已经获得锁的会话超时了,因为创建的是临时节点,所以该会话对应的临时节点会被删除,其它会话就可以获得锁了。可以看到,这种实现方式不会出现数据库的唯一索引实现方式释放锁失败的问题。

  6. 羊群效应:一个节点未获得锁,只需要监听自己的前一个子节点,这是因为如果监听所有的子节点,那么任意一个子节点状态改变,其它所有子节点都会收到通知(羊群效应,一只羊动起来,其它羊也会一哄而上),而我们只希望它的后一个子节点收到通知。

无锁

CAS

乐观锁需要操作和冲突检测这两个步骤具备原子性,这里就不能再使用互斥同步来保证了,只能靠硬件来完成。硬件支持的原子性操作最典型的是:比较并交换(Compare-and-Swap,CAS)。CAS 指令需要有 3 个操作数,分别是内存地址 V、旧的预期值 A 和新值 B。当执行操作时,只有当 V 的值等于 A,才将 V 的值更新为 B。

CAS的ABA问题

如果一个变量初次读取的时候是 A 值,它的值被改成了 B,后来又被改回为 A,那 CAS 操作就会误认为它从来没有被改变过。

J.U.C 包提供了一个带有标记的原子引用类 AtomicStampedReference 来解决这个问题,它可以通过控制变量值的版本来保证 CAS 的正确性。大部分情况下 ABA 问题不会影响程序并发的正确性,如果需要解决 ABA 问题,改用传统的互斥同步可能会比原子类更高效。

锁优化

这里的锁优化主要是指 JVM 对 synchronized 的优化。

偏向锁

偏向锁的思想是偏向于让第一个获取锁对象的线程,这个线程在之后获取该锁就不再需要进行同步操作,甚至连 CAS 操作也不再需要。

当锁对象第一次被线程获得的时候,进入偏向状态,标记为 1 01。同时使用 CAS 操作将线程 ID 记录到 Mark Word 中,如果 CAS 操作成功,这个线程以后每次进入这个锁相关的同步块就不需要再进行任何同步操作。

当有另外一个线程去尝试获取这个锁对象时,偏向状态就宣告结束,此时撤销偏向(Revoke Bias)后恢复到未锁定状态或者轻量级锁状态。

引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗必须小于节省下来的CAS原子指令的性能消耗)。上面说过,轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能。

轻量级锁

JDK 1.6 引入了偏向锁和轻量级锁,从而让锁拥有了四个状态:无锁状态(unlocked)、偏向锁状态(biasble)、轻量级锁状态(lightweight locked)和重量级锁状态(inflated)。

以下是 HotSpot 虚拟机对象头的内存布局,这些数据被称为 Mark Word。其中 tag bits 对应了五个状态,这些状态在右侧的 state 表格中给出。除了 marked for gc 状态,其它四个状态已经在前面介绍过了。

bb6a49be-00f2-4f27-a0ce-4ed764bc605c

下图左侧是一个线程的虚拟机栈,其中有一部分称为 Lock Record 的区域,这是在轻量级锁运行过程创建的,用于存放锁对象的 Mark Word。而右侧就是一个锁对象,包含了 Mark Word 和其它信息。

051e436c-0e46-4c59-8f67-52d89d656182

轻量级锁是相对于传统的重量级锁而言,它使用 CAS 操作来避免重量级锁使用互斥量的开销。对于绝大部分的锁,在整个同步周期内都是不存在竞争的,因此也就不需要都使用互斥量进行同步,可以先采用 CAS 操作进行同步,如果 CAS 失败了再改用互斥量进行同步。

  • 当单一线程尝试获取一个锁对象时,如果锁对象标记为 01,说明锁对象的锁未锁定(unlocked)状态,则获取该对象,同时处于偏向锁状态。则尝试用 CAS 操作, 将自己的线程 ID 写入MarkWord。此时虚拟机在当前线程的虚拟机栈中创建 Lock Record,然后使用 CAS 操作将对象的 Mark Word 更新为 Lock Record 指针。如果 CAS 操作成功了,那么线程就获取了该对象上的锁。

    baaa681f-7c52-4198-a5ae-303b9386cf47

  • 如果 CAS 操作失败, 则说明, 有另外一个线程 Thread B 抢先获取了偏向锁。 这种状态说明该对象的竞争比较激烈, 此时需要撤销 Thread B 获得的偏向锁,将 Thread B 持有的锁升级为轻量级锁。对象的 Mark Word 的锁标记变为 00,表示该对象处于轻量级锁状态。此时线程Thread A开始自旋的进行CAS尝试获取锁。

  • 如果 CAS 操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的虚拟机栈,如果是的话说明当前线程已经拥有了这个锁对象,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程线程抢占了。此时说明锁竞争激烈,那轻量级锁就不再有效,要膨胀为重量级锁。

重量级锁

Synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的。但是监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”。

锁消除

锁消除是指对于被检测出不可能存在竞争的共享数据的锁进行消除。

锁消除主要是通过逃逸分析来支持,如果堆上的共享数据不可能逃逸出去被其它线程访问到,那么就可以把它们当成私有数据对待,也就可以将它们的锁进行消除。

对于一些看起来没有加锁的代码,其实隐式的加了很多锁。例如下面的字符串拼接代码就隐式加了锁:

public static String concatString(String s1, String s2, String s3) {
    return s1 + s2 + s3;
}

String 是一个不可变的类,编译器会对 String 的拼接自动优化。在 JDK 1.5 之前,会转化为 StringBuffer 对象的连续 append() 操作:

public static String concatString(String s1, String s2, String s3) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    sb.append(s3);
    return sb.toString();
}

每个 append() 方法中都有一个同步块。虚拟机观察变量 sb,很快就会发现它的动态作用域被限制在 concatString() 方法内部。也就是说,sb 的所有引用永远不会逃逸到 concatString() 方法之外,其他线程无法访问到它,因此可以进行消除。

锁粗化

如果一系列的连续操作都对同一个对象反复加锁和解锁,频繁的加锁操作就会导致性能损耗。

上一节的示例代码中连续的 append() 方法就属于这类情况。如果虚拟机探测到由这样的一串零碎的操作都对同一个对象加锁,将会把加锁的范围扩展(粗化)到整个操作序列的外部。对于上一节的示例代码就是扩展到第一个 append() 操作之前直至最后一个 append() 操作之后,这样只需要加锁一次就可以了。

自旋锁

互斥同步进入阻塞状态的开销都很大,应该尽量避免。在许多应用中,共享数据的锁定状态只会持续很短的一段时间。自旋锁的思想是让一个线程在请求一个共享数据的锁时执行忙循环(自旋)一段时间,如果在这段时间内能获得锁,就可以避免进入阻塞状态。

自旋锁虽然能避免进入阻塞状态从而减少开销,但是它需要进行忙循环操作占用 CPU 时间,它只适用于共享数据的锁定状态很短的场景。

在 JDK 1.6 中引入了自适应的自旋锁。自适应意味着自旋的次数不再固定了,而是由前一次在同一个锁上的自旋次数及锁的拥有者的状态来决定。

什么是自旋锁?

自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。

获取锁的线程一直处于活跃状态,但是并没有执行任何有效的任务,使用这种锁会造成busy-waiting

它是为实现保护共享资源而提出一种锁机制。其实,自旋锁与互斥锁比较类似,它们都是为了解决对某项资源的互斥使用。无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者,也就说,在任何时刻最多只能有一个执行单元获得锁。但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态。但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,”自旋”一词就是因此而得名。

Java如何实现自旋锁?

下面是个简单的例子:

public class SpinLock {
    private AtomicReference<Thread> cas = new AtomicReference<Thread>();
    public void lock() {
        Thread current = Thread.currentThread();
        // 利用CAS
        while (!cas.compareAndSet(null, current)) {
            // DO nothing
        }
    }
    public void unlock() {
        Thread current = Thread.currentThread();
        cas.compareAndSet(current, null);
    }
}

lock()方法利用的CAS,当第一个线程A获取锁的时候,能够成功获取到,不会进入while循环,如果此时线程A没有释放锁,另一个线程B又来获取锁,此时由于不满足CAS,所以就会进入while循环,不断判断是否满足CAS,直到A线程调用unlock方法释放了该锁。

自旋锁存在的问题

  1. 如果某个线程持有锁的时间过长,就会导致其它等待获取锁的线程进入循环等待,消耗CPU。使用不当会造成CPU使用率极高。
  2. 上面Java实现的自旋锁不是公平的,即无法满足等待时间最长的线程优先获取锁。不公平的锁就会存在“线程饥饿”问题。

对于自旋锁来说,
1、若有同一线程两调用lock() ,会导致第二次调用lock位置进行自旋,产生了死锁
说明这个锁并不是可重入的。(在lock函数内,应验证线程是否为已经获得锁的线程)
2、若1问题已经解决,当unlock()第一次调用时,就已经将锁释放了。实际上不应释放锁。
(采用计数次进行统计)
修改之后,如下:

public class SpinLock1 {
    private AtomicReference<Thread> owner =new AtomicReference<>();
    private int count =0;
    public void lock(){
        Thread current = Thread.currentThread();
        if(current==owner.get()) {
            count++;
            return ;
        }
        while(!owner.compareAndSet(null, current)){
        }
    }
    public void unlock (){
        Thread current = Thread.currentThread();
        if(current==owner.get()){
            if(count!=0){
                count--;
            }else{
                owner.compareAndSet(current, null);
            }
        }
    }
}

该自旋锁即为可重入锁。

死锁

什么是死锁

一、什么是死锁

死锁不仅在个人学习中,甚至在开发中也并不常见。但是一旦出现死锁,后果将非常严重。 首先什么是死锁呢?打个比方,就好像有两个人打架,互相限制住了(锁住,抱住)彼此一样,互相动弹不得,而且互相欧气,你不松手我就不松手。好了谁也动弹不得。 在多线程的环境下,势必会对资源进行抢夺。当两个线程锁住了当前资源,但都需要对方的资源才能进行下一步操作,这个时候两方就会一直等待对方的资源释放。这就形成了死锁。这些永远在互相等待的进程称为死锁进程。

那么我们来总结一下死锁产生的条件:

  1. 互斥:资源的锁是排他性的,加锁期间只能有一个线程拥有该资源。其他线程只能等待锁释放才能尝试获取该资源。
  2. 请求和保持:当前线程已经拥有至少一个资源,但其同时又发出新的资源请求,而被请求的资源被其他线程拥有。此时进入保持当前资源并等待下个资源的状态。
  3. 不剥夺:线程已拥有的资源,只能由自己释放,不能被其他线程剥夺。
  4. 循环等待:是指有多个线程互相的请求对方的资源,但同时拥有对方下一步所需的资源。形成一种循环,类似2)请求和保持。但此处指多个线程的关系。并不是指单个线程一直在循环中等待。

什么?还是不理解?那我们直接上代码,动手写一个死锁。

二、动手写死锁

根据条件,我们让两个线程互相请求保持。

public class DeadLockDemo implements Runnable{

    public static int flag = 1;

    //static 变量是 类对象共享的
    static Object o1 = new Object();
    static Object o2 = new Object();

    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + ":此时 flag = " + flag);
        if(flag == 1){
            synchronized (o1){
                try {
                    System.out.println("我是" + Thread.currentThread().getName() + "锁住 o1");
                    Thread.sleep(3000);
                    System.out.println(Thread.currentThread().getName() + "醒来->准备获取 o2");
                }catch (Exception e){
                    e.printStackTrace();
                }
                synchronized (o2){
                    System.out.println(Thread.currentThread().getName() + "拿到 o2");//第24行
                }
            }
        }
        if(flag == 0){
            synchronized (o2){
                try {
                    System.out.println("我是" + Thread.currentThread().getName() + "锁住 o2");
                    Thread.sleep(3000);
                    System.out.println(Thread.currentThread().getName() + "醒来->准备获取 o2");
                }catch (Exception e){
                    e.printStackTrace();
                }
                synchronized (o1){
                    System.out.println(Thread.currentThread().getName() + "拿到 o1");//第38行
                }
            }
        }
    }

    public static  void main(String args[]){

        DeadLockDemo t1 = new DeadLockDemo();
        DeadLockDemo t2 = new DeadLockDemo();
        t1.flag = 1;
        new Thread(t1).start();

        //让main线程休眠1秒钟,保证t2开启锁住o2.进入死锁
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        t2.flag = 0;
        new Thread(t2).start();

    }
}

代码中, t1创建,t1先拿到o1的锁,开始休眠3秒。然后 t2线程创建,t2拿到o2的锁,开始休眠3秒。然后 t1先醒来,准备拿o2的锁,发现o2已经加锁,只能等待o2的锁释放。 t2后醒来,准备拿o1的锁,发现o1已经加锁,只能等待o1的锁释放。 t1,t2形成死锁。

我们查看运行状态,

20190329173537340

三、发现排查死锁情况

我们利用jdk提供的工具定位死锁问题:

  1. jps显示所有当前Java虚拟机进程名及pid.
  2. jstack打印进程堆栈信息。

20190329174354777

列出所有java进程。 我们检查一下DeadLockDemo,为什么这个线程不退栈。

jstack 11170

20190329174417873

我们直接翻到最后:已经检测出了一个java级别死锁。其中两个线程分别卡在了代码第38行和第24行。检查我们代码的对应位置,即可排查错误。此处我们是第二个锁始终拿不到,所以死锁了。

20190329174407208

四、解决办法

死锁一旦发生,我们就无法解决了。所以我们只能避免死锁的发生。 既然死锁需要满足四种条件,那我们就从条件下手,只要打破任意规则即可。

  1. (互斥)尽量少用互斥锁,能加读锁,不加写锁。当然这条无法避免。
  2. (请求和保持)采用资源静态分配策略(进程资源静态分配方式是指一个进程在建立时就分配了它需要的全部资源).我们尽量不让线程同时去请求多个锁,或者在拥有一个锁又请求不到下个锁时,不保持等待,先释放资源等待一段时间在重新请求。
  3. (不剥夺)允许进程剥夺使用其他进程占有的资源。优先级。
  4. (循环等待)尽量调整获得锁的顺序,不发生嵌套资源请求。加入超时。

如何避免死锁

我们只要破坏产生死锁的四个条件中的其中一个就可以了。

破坏互斥条件

这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问)。

破坏请求与保持条件

一次性申请所有的资源。

破坏不剥夺条件

占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。

破坏循环等待条件

靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。

死锁问题如何排查

我们利用jdk提供的工具定位死锁问题:

  1. jps显示所有当前Java虚拟机进程名及pid.
  2. jstack打印进程堆栈信息。

synchronized

synchronized和lock之间关系

synchronized

1. 同步一个代码块

public void func() {
    synchronized (this) {
        // ...
    }
}

它只作用于同一个对象,如果调用两个对象上的同步代码块,就不会进行同步。

对于以下代码,使用 ExecutorService 执行了两个线程,由于调用的是同一个对象的同步代码块,因此这两个线程会进行同步,当一个线程进入同步语句块时,另一个线程就必须等待。

public class SynchronizedExample {

    public void func1() {
        synchronized (this) {
            for (int i = 0; i < 10; i++) {
                System.out.print(i + " ");
            }
        }
    }
}
public static void main(String[] args) {
    SynchronizedExample e1 = new SynchronizedExample();
    ExecutorService executorService = Executors.newCachedThreadPool();
    executorService.execute(() -> e1.func1());
    executorService.execute(() -> e1.func1());
}
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9

对于以下代码,两个线程调用了不同对象的同步代码块,因此这两个线程就不需要同步。从输出结果可以看出,两个线程交叉执行。

public static void main(String[] args) {
    SynchronizedExample e1 = new SynchronizedExample();
    SynchronizedExample e2 = new SynchronizedExample();
    ExecutorService executorService = Executors.newCachedThreadPool();
    executorService.execute(() -> e1.func1());
    executorService.execute(() -> e2.func1());
}
0 0 1 1 2 2 3 3 4 4 5 5 6 6 7 7 8 8 9 9

2. 同步一个方法

public synchronized void func () {
    // ...
}

它和同步代码块一样,作用于同一个对象。

3. 同步一个类

public void func() {
    synchronized (SynchronizedExample.class) {
        // ...
    }
}

作用于整个类,也就是说两个线程调用同一个类的不同对象上的这种同步语句,也会进行同步。

public class SynchronizedExample {

    public void func2() {
        synchronized (SynchronizedExample.class) {
            for (int i = 0; i < 10; i++) {
                System.out.print(i + " ");
            }
        }
    }
}
public static void main(String[] args) {
    SynchronizedExample e1 = new SynchronizedExample();
    SynchronizedExample e2 = new SynchronizedExample();
    ExecutorService executorService = Executors.newCachedThreadPool();
    executorService.execute(() -> e1.func2());
    executorService.execute(() -> e2.func2());
}
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9

4. 同步一个静态方法

public synchronized static void fun() {
    // ...
}

作用于整个类。

ReentrantLock

ReentrantLock 是 java.util.concurrent(J.U.C)包中的锁。

public class LockExample {

    private Lock lock = new ReentrantLock();

    public void func() {
        lock.lock();
        try {
            for (int i = 0; i < 10; i++) {
                System.out.print(i + " ");
            }
        } finally {
            lock.unlock(); // 确保释放锁,从而避免发生死锁。
        }
    }
}
public static void main(String[] args) {
    LockExample lockExample = new LockExample();
    ExecutorService executorService = Executors.newCachedThreadPool();
    executorService.execute(() -> lockExample.func());
    executorService.execute(() -> lockExample.func());
}
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9

比较

1. 锁的实现

synchronized 是 JVM 实现的,而 ReentrantLock 是 JDK 实现的。

2. 性能

新版本 Java 对 synchronized 进行了很多优化,例如自旋锁等,synchronized 与 ReentrantLock 大致相同。

3. 等待可中断

当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。

ReentrantLock 可中断,而 synchronized 不行。

4. 公平锁

公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁。

synchronized 中的锁是非公平的,ReentrantLock 默认情况下也是非公平的,但是也可以是公平的。

5. 锁绑定多个条件

一个 ReentrantLock 可以同时绑定多个 Condition 对象。

使用选择

除非需要使用 ReentrantLock 的高级功能,否则优先使用 synchronized。这是因为 synchronized 是 JVM 实现的一种锁机制,JVM 原生地支持它,而 ReentrantLock 不是所有的 JDK 版本都支持。并且使用 synchronized 不用担心没有释放锁而导致死锁问题,因为 JVM 会确保锁的释放。

volatile

volatile的实现原理

再有人问你Java内存模型是什么,就把这篇文章发给他中我们曾经介绍过,为了提高处理器的执行速度,在处理器和内存之间增加了多级缓存来提升。但是由于引入了多级缓存,就存在缓存数据不一致问题。

但是,对于volatile变量,当对volatile变量进行写操作的时候,JVM会向处理器发送一条lock前缀的指令,将这个缓存中的变量回写到系统主存中。

但是就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题,所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议

缓存一致性协议:每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里。

所以,如果一个变量被volatile所修饰的话,在每次数据变化之后,其值都会被强制刷入主存。而其他处理器的缓存由于遵守了缓存一致性协议,也会把这个变量的值从主存加载到自己的缓存中。这就保证了一个volatile在并发编程中,其值在多个缓存中是可见的。

内存屏障

先给出结论:volatile是通过内存屏障来来禁止指令重排的。

**内存屏障(Memory Barrier)**是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。下表描述了和volatile有关的指令重排禁止行为:

volatile

从上表我们可以看出:

当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。

当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。

当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

具体实现方式是在编译期生成字节码时,会在指令序列中增加内存屏障来保证,下面是基于保守策略的JMM内存屏障插入策略:

  • 在每个volatile写操作的前面插入一个

    StoreStore
    

    屏障。

    • 对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
  • 在每个volatile写操作的后面插入一个

    StoreLoad
    

    屏障。

    • 对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。
  • 在每个volatile读操作的后面插入一个

    LoadLoad
    

    屏障。

    • 对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
  • 在每个volatile读操作的后面插入一个

    LoadStore
    

    屏障。

    • 对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。

fences-table

所以,volatile通过在volatile变量的操作前后插入内存屏障的方式,来禁止指令重排,进而保证多线程情况下对共享变量的有序性。

有了synchronized为什么还需要volatile

synchronized的问题

我们都知道synchronized其实是一种加锁机制,那么既然是锁,天然就具备以下几个缺点:

1、有性能损耗

虽然在JDK 1.6中对synchronized做了很多优化,如如适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁等(深入理解多线程(五)—— Java虚拟机的锁优化技术),但是他毕竟还是一种锁。

以上这几种优化,都是尽量想办法避免对Monitor(深入理解多线程(四)—— Moniter的实现原理)进行加锁,但是,并不是所有情况都可以优化的,况且就算是经过优化,优化的过程也是有一定的耗时的。

所以,无论是使用同步方法还是同步代码块,在同步操作之前还是要进行加锁,同步操作之后需要进行解锁,这个加锁、解锁的过程是要有性能损耗的。

关于二者的性能对比,由于虚拟机对锁实行的许多消除和优化,使得我们很难量化这两者之间的性能差距,但是我们可以确定的一个基本原则是:volatile变量的读操作的性能小号普通变量几乎无差别,但是写操作由于需要插入内存屏障所以会慢一些,即便如此,volatile在大多数场景下也比锁的开销要低。

2、产生阻塞

我们在深入理解多线程(一)——Synchronized的实现原理中介绍过关于synchronize的实现原理,无论是同步方法还是同步代码块,无论是ACC_SYNCHRONIZED还是monitorenter、monitorexit都是基于Monitor实现的。

基于Monitor对象,当多个线程同时访问一段同步代码时,首先会进入Entry Set,当有一个线程获取到对象的锁之后,才能进行The Owner区域,其他线程还会继续在Entry Set等待。并且当某个线程调用了wait方法后,会释放锁并进入Wait Set等待。

15660298698995

所以,synchronize实现的锁本质上是一种阻塞锁,也就是说多个线程要排队访问同一个共享对象。

而volatile是Java虚拟机提供的一种轻量级同步机制,他是基于内存屏障实现的。说到底,他并不是锁,所以他不会有synchronized带来的阻塞和性能损耗的问题。

volatile的附加功能

除了前面我们提到的volatile比synchronized性能好以外,volatile其实还有一个很好的附加功能,那就是禁止指令重排。

我们先来举一个例子,看一下如果只使用synchronized而不使用volatile会发生什么问题,就拿我们比较熟悉的单例模式来看。

我们通过双重校验锁的方式实现一个单例,这里不使用volatile关键字:

 1   public class Singleton {  
 2      private static Singleton singleton;  
 3       private Singleton (){}  
 4       public static Singleton getSingleton() {  
 5       if (singleton == null) {  
 6           synchronized (Singleton.class) {  
 7               if (singleton == null) {  
 8                   singleton = new Singleton();  
 9               }  
 10           }  
 11       }  
 12       return singleton;  
 13       }  
 14   }  

以上代码,我们通过使用synchronized对Singleton.class进行加锁,可以保证同一时间只有一个线程可以执行到同步代码块中的内容,也就是说singleton = new Singleton()这个操作只会执行一次,这就是实现了一个单例。

但是,当我们在代码中使用上述单例对象的时候有可能发生空指针异常。这是一个比较诡异的情况。

我们假设Thread1 和 Thread2两个线程同时请求Singleton.getSingleton方法的时候:

15660330700367

Step1 ,Thread1执行到第8行,开始进行对象的初始化。 Step2 ,Thread2执行到第5行,判断singleton == null。 Step3 ,Thread2经过判断发现singleton != null,所以执行第12行,返回singleton。 Step4 ,Thread2拿到singleton对象之后,开始执行后续的操作,比如调用singleton.call()。

以上过程,看上去并没有什么问题,但是,其实,在Step4,Thread2在调用singleton.call()的时候,是有可能抛出空指针异常的。

之所有会有NPE抛出,是因为在Step3,Thread2拿到的singleton对象并不是一个完整的对象。

我们这里来分析一下,singleton = new Singleton();这行代码到底做了什么事情,大致过程如下:

1、虚拟机遇到new指令,到常量池定位到这个类的符号引用。 2、检查符号引用代表的类是否被加载、解析、初始化过。 3、虚拟机为对象分配内存。 4、虚拟机将分配到的内存空间都初始化为零值。 5、虚拟机对对象进行必要的设置。 6、执行方法,成员变量进行初始化。 7、将对象的引用指向这个内存区域。

我们把这个过程简化一下,简化成3个步骤:

a、JVM为对象分配一块内存M b、在内存M上为对象进行初始化 c、将内存M的地址复制给singleton变量

15660328514676

因为将内存的地址赋值给singleton变量是最后一步,所以Thread1在这一步骤执行之前,Thread2在对singleton==null进行判断一直都是true的,那么他会一直阻塞,直到Thread1将这一步骤执行完。

但是,以上过程并不是一个原子操作,并且编译器可能会进行重排序,如果以上步骤被重排成:

a、JVM为对象分配一块内存M c、将内存的地址复制给singleton变量 b、在内存M上为对象进行初始化

15660329140984

这样的话,Thread1会先执行内存分配,在执行变量赋值,最后执行对象的初始化,那么,也就是说,在Thread1还没有为对象进行初始化的时候,Thread2进来判断singleton==null就可能提前得到一个false,则会返回一个不完整的sigleton对象,因为他还未完成初始化操作。

这种情况一旦发生,我们拿到了一个不完整的singleton对象,当尝试使用这个对象的时候就极有可能发生NPE异常。

那么,怎么解决这个问题呢?因为指令重排导致了这个问题,那就避免指令重排就行了。

所以,volatile就派上用场了,因为volatile可以避免指令重排。只要将代码改成以下代码,就可以解决这个问题:

 1   public class Singleton {  
 2      private volatile static Singleton singleton;  
 3       private Singleton (){}  
 4       public static Singleton getSingleton() {  
 5       if (singleton == null) {  
 6           synchronized (Singleton.class) {  
 7               if (singleton == null) {  
 8                   singleton = new Singleton();  
 9               }  
 10           }  
 11       }  
 12       return singleton;  
 13       }  
 14   }  

对singleton使用volatile约束,保证他的初始化过程不会被指令重排。

synchronized的有序性保证呢?

看到这里可能有朋友会问了,说到底上面问题还是个有序性的问题,不是说synchronized是可以保证有序性的么,这里为什么就不行了呢?

首先,可以明确的一点是:synchronized是无法禁止指令重排和处理器优化的。那么他是如何保证的有序性呢?

这就要再把有序性的概念扩展一下了。Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有操作都是天然有序的。如果在一个线程中观察另一个线程,所有操作都是无序的。

以上这句话也是《深入理解Java虚拟机》中的原句,但是怎么理解呢?周志明并没有详细的解释。这里我简单扩展一下,这其实和as-if-serial语义有关。

as-if-serial语义的意思指:不管怎么重排序,单线程程序的执行结果都不能被改变。编译器和处理器无论如何优化,都必须遵守as-if-serial语义。

这里不对as-if-serial语义详细展开了,简单说就是,as-if-serial语义保证了单线程中,不管指令怎么重排,最终的执行结果是不能被改变的。

那么,我们回到刚刚那个双重校验锁的例子,站在单线程的角度,也就是只看Thread1的话,因为编译器会遵守as-if-serial语义,所以这种优化不会有任何问题,对于这个线程的执行结果也不会有任何影响。

但是,Thread1内部的指令重排却对Thread2产生了影响。

那么,我们可以说,synchronized保证的有序性是多个线程之间的有序性,即被加锁的内容要按照顺序被多个线程执行。但是其内部的同步代码还是会发生重排序,只不过由于编译器和处理器都遵循as-if-serial语义,所以我们可以认为这些重排序在单线程内部可忽略。

总结

本文从两方面论述了volatile的重要性以及不可替代性:

一方面是因为synchronized是一种锁机制,存在阻塞问题和性能问题,而volatile并不是锁,所以不存在阻塞和性能问题。

另外一方面,因为volatile借助了内存屏障来帮助其解决可见性和有序性问题,而内存屏障的使用还为其带来了一个禁止指令重排的附件功能,所以在有些场景中是可以避免发生指令重排的问题的。

线程相关方法

start & run

这是另一个非常经典的 java 多线程面试问题,而且在面试中会经常被问到。很简单,但是很多人都会答不上来!

new 一个 Thread,线程进入了新建状态;调用 start() 方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。 而直接执行 run() 方法,会把 run 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。

sleep 和 wait

  • 两者最主要的区别在于:sleep 方法没有释放锁,而 wait 方法释放了锁
  • 两者都可以暂停线程的执行。
  • Wait 通常被用于线程间交互/通信,sleep 通常被用于暂停执行。
  • wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法。sleep() 方法执行完成后,线程会自动苏醒。或者可以使用wait(long timeout)超时后线程会自动苏醒。

notify & notifyAll

调用 wait() 使得线程等待某个条件满足,线程在等待时会被挂起,当其他线程的运行使得这个条件满足时,其它线程会调用 notify() 或者 notifyAll() 来唤醒挂起的线程。

它们都属于 Object 的一部分,而不属于 Thread。

只能用在同步方法或者同步控制块中使用,否则会在运行时抛出 IllegalMonitorStateException。

使用 wait() 挂起期间,线程会释放锁。这是因为,如果没有释放锁,那么其它线程就无法进入对象的同步方法或者同步控制块中,那么就无法执行 notify() 或者 notifyAll() 来唤醒挂起的线程,造成死锁。

public class WaitNotifyExample {

    public synchronized void before() {
        System.out.println("before");
        notifyAll();
    }

    public synchronized void after() {
        try {
            wait();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("after");
    }
}
public static void main(String[] args) {
    ExecutorService executorService = Executors.newCachedThreadPool();
    WaitNotifyExample example = new WaitNotifyExample();
    executorService.execute(() -> example.after());
    executorService.execute(() -> example.before());
}
before
after

wait() 和 sleep() 的区别

  • wait() 是 Object 的方法,而 sleep() 是 Thread 的静态方法;
  • wait() 会释放锁,sleep() 不会。

ThreadLocal

ThreadLocal 原理

如果你从字面上来理解,很容易将 ThreadLocal 理解为『本地线程』,那么你就大错特错了。首先,ThreadLocal 不是线程,更不是本地线程,而是 Thread 的局部变量,也许把它命名为ThreadLocalVariable更容易让人理解一些。它是每个线程独享的本地变量,每个线程都有自己的 ThreadLocal,它们是线程隔离的。接下来,我们通过一个生活案例来开始理解 ThreadLocal。

一、问题场景引入

假如语文老师有一本书,但是班上有30名学生,老师将这本书送给学生们去阅读,30名学生都想阅读这本书。

为保证每个学生都能阅读到书籍,那么基本可以有两种方案,一是按照某种排序(例如姓名首字母排序),让每个学生依次阅读。

二是让30名学生同时争抢,谁抢到谁就去阅读,读完放回原处,剩下的29名学生再次争抢。

显然第一种方案,基本表现为串行阅读,时间成本较大,第二种方案为多个学生争抢,容易发生安全问题(学生发生冲突或者书籍在争抢过程中被毁坏)。

为了解决这两个问题,那么有没有更加好的方案呢?当然有,老师可以将书籍复印30本,每个学生都发一本,这样既大大提高了阅读效率,节约了阅读时间,还能保证每个学生都能有自己的书籍,这样就不会发生争抢,避免了安全问题。

其实阅读到这里,读者应该有点感觉了,因为生动的例子能帮助读者迅速理解关键点,在本例中,书籍作为共享变量,那么很多学生去争抢,学生可以理解为线程,同时去争抢(并发执行)有很大可能会引起安全问题(线程安全问题),这往往是老师不愿意看到的后果。

我们在结合Java Demo来演示类似的案例。假如我们有一个需求,那就是在多线程环境下,去格式化时间为指定格式yyyy-MM-dd HH:mm:ss,假设一开始只有两个线程需要这么做,代码如下:

public class ThreadLocalUsage01 {

    public static void main(String[] args) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                String date = new ThreadLocalUsage01().date(10);
                System.out.println(date);
            }
        }).start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                String date = new ThreadLocalUsage01().date(1000);
                System.out.println(date);
            }
        }).start();
    }

    private String date(int seconds) {
        // 参数的单位是毫秒,从1970.1.1 00:00:00 GMT计时
        Date date = new Date(1000 * seconds);
        SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        return dateFormat.format(date);
    }

}

在线程少的情况下是没有问题的,我们在每个线程里调用date方法,也就是在每个线程里都执行了创建SimpleDateFormat对象,每个对象在各自的线程里面执行格式化时间

但是我们是否会思考到,假如有1000个线程需要格式化时间,那么需要调用1000次date方法,也就是需要创建1000个作用一样的SimpleDateFormat对象,这样是不是太浪费内存了?也给GC带来压力?

于是我们联想到,1000个线程来共享一个SimpleDateFormat对象,这样SimpleDateFormat对象只需要创建一次即可,代码如下:

public class ThreadLocalUsage02 {

    public static ExecutorService THREAD_POOL = Executors.newFixedThreadPool(10);
    static SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 1000; i++) {
            int finalI = i;
            THREAD_POOL.submit(new Runnable() {
                @Override
                public void run() {
                    String date = new ThreadLocalUsage02().date(finalI);
                    System.out.println(date);
                }
            });
        }
        // 关闭线程池,此种关闭方式不再接受新的任务提交,等待现有队列中的任务全部执行完毕之后关闭
        THREAD_POOL.shutdown();
    }

    private String date(int seconds) {
        // 参数的单位是毫秒,从1970.1.1 00:00:00 GMT计时
        Date date = new Date(1000 * seconds);
        return DATE_FORMAT.format(date);
    }

}

上述代码我们使用到了固定线程数的线程池来执行时间格式化任务,我们来执行一下,看看结果:

0

截取了部分执行结果,发现执行结果中有很多重复的时间格式化内容,这是为什么呢?

这是因为SimpleDateFormat是一个线程不安全的类,其实例对象在多线程环境下作为共享数据,会发生线程不安全问题。

说到这里,很多读者肯定会说,我们可以尝试一下使用锁机制,我们将date方法内的格式化代码使用synchronized关键字概括起来,保证同一时刻只能有一个线程来访问SimpleDateFormat的format方法,代码如下所示:

private String date(int seconds) {
    // 参数的单位是毫秒,从1970.1.1 00:00:00 GMT计时
    Date date = new Date(1000 * seconds);
    String format;
    synchronized (ThreadLocalUsage02.class) {
        format = DATE_FORMAT.format(date);
    }
    return format;
}

有了锁的保证,那么这次执行后就不会再出现重复的时间格式化结果了,这也就保证了线程安全。

使用锁机制确实可以解决问题,但是多数情况下,我们不大愿意使用锁,因为锁的使用会带来性能的下降(比如10个线程重复排队执行DATE_FORMAT.format(date)代码),那么有没有其他方法来解决这个问题呢?答案当然是有,那就是本文的主角——ThreadLocal。

二、理解ThreadLocal的用法

这里还是使用固定线程数的线程池来执行格式化时间的任务。

我们的基本思想是,使用ThreadLocal来给线程池中每个线程赋予一个SimpleDateFormat对象副本,该副本只能被当前线程使用,是当前线程独享的成员变量,当SimpleDateFormat对象不存在多线程共同访问的时候,也就不会产生线程安全问题了,基本原理图如下所示:

1

我们使用ThreadLocal的目的是为了避免创建1000个SimpleDateFormat对象,且在不使用锁的情况下保证线程安全,那么如何实现只创建一个SimpleDateFormat对象且能被多个线程同时使用呢?改造后的案例代码如下所示:

public class ThreadLocalUsage04 {

    public static ExecutorService THREAD_POOL = Executors.newFixedThreadPool(10);

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 1000; i++) {
            int finalI = i;
            THREAD_POOL.submit(new Runnable() {
                @Override
                public void run() {
                    String date = new ThreadLocalUsage04().date(finalI);
                    System.out.println(date);
                }
            });
        }
        THREAD_POOL.shutdown();
    }

    private String date(int seconds) {
        // 参数的单位是毫秒,从1970.1.1 00:00:00 GMT计时
        Date date = new Date(1000 * seconds);
        SimpleDateFormat simpleDateFormat = ThreadSafeDateFormatter.dateFormatThreadLocal.get();
        return simpleDateFormat.format(date);
    }

}

class ThreadSafeDateFormatter {

    public static ThreadLocal<SimpleDateFormat> dateFormatThreadLocal = new ThreadLocal<SimpleDateFormat>() {
        @Override
        protected SimpleDateFormat initialValue() {
            return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        }
    };

}

上面的代码使用到了ThreadLocal,将SimpleDateFormat对象用ThreadLocal包装了一层,使得多个线程内部都有一个SimpleDateFormat对象副本,每个线程使用自己的SimpleDateFormat,这样就不会产生线程安全问题了。

那么以上介绍的是ThreadLocal的第一大场景的使用,也就是利用到了ThreadLocal的initialValue()方法,使得每个线程内都具备了一个SimpleDateFormat副本。

接下来我们一起来看看ThreadLocal的第二大使用场景,在使用之前,我们先把两个场景总结如下:

  • 场景1:每个线程需要一个独享的对象,通常是工具类,比如典型的SimpleDateFormat和Random等。
  • 场景2:每个线程内需要保存线程内的全局变量,这样线程在执行多个方法的时候,可以在多个方法中获取这个线程内的全局变量,避免了过度参数传递的问题。

那么如何理解第二个问题呢?我们还是使用一个Demo来理解:假设有一个学生类,类成员变量包括姓名,性别,成绩,我们需要定义三个方法来分别获取学生的姓名、性别和成绩,那么我们传统的做法是:

public class ThreadLocalUsage05 {

    public static void main(String[] args) {
        Student student = init();
        new NameService().getName(student);
        new SexService().getSex(student);
        new ScoreService().getScore(student);
    }

    private static Student init() {
        Student student = new Student();
        student.name = "Lemon";
        student.sex = "female";
        student.score = "100";
        return student;
    }

}

class Student {

    /**
     * 姓名、性别、成绩
     */
    String name;
    String sex;
    String score;

}

class NameService {

    public void getName(Student student) {
        System.out.println(student.name);
    }

}

class SexService {

    public void getSex(Student student) {
        System.out.println(student.sex);
    }

}

class ScoreService {

    public void getScore(Student student) {
        System.out.println(student.score);
    }

}

从上面的代码中可以看出,每个类的方法都需要传递学生的信息才可以获取到正确的信息,这样做能达到目的

但是每个方法都需要学生信息作为入参,这样未免有点繁琐,且在实际使用中通常在每个方法里面还需要对每个学生信息进行判空,这样的代码显得十分冗余,不利于维护。

也许有人会说,我们可以将学生信息存入到一个共享的Map中,需要学生信息的时候直接去Map中取,如下图所示:

2

其实这也是一种思路,但是在并发环境下,如果要使用Map,那么就需要使用同步的Map,比如ConcurrentHashMap或者Collections.SynchronizedMap(),前者底层用的是CAS和锁机制,后者直接使用的是synchronized,性能也不尽人意。

其实,我们可以将学生信息存入到ThreadLocal中,在同一个线程中,那么直接从ThreadLocal中获取需要的信息即可!案例代码如下所示:

public class ThreadLocalUsage05 {

    public static void main(String[] args) {
        init();
        new NameService().getName();
        new SexService().getSex();
        new ScoreService().getScore();
    }

    private static void init() {
        Student student = new Student();
        student.name = "Lemon";
        student.sex = "female";
        student.score = "100";
        ThreadLocalProcessor.studentThreadLocal.set(student);
    }

}

class ThreadLocalProcessor {

    public static ThreadLocal<Student> studentThreadLocal = new ThreadLocal<>();

}

class Student {

    /**
     * 姓名、性别、成绩
     */
    String name;
    String sex;
    String score;

}

class NameService {

    public void getName() {
        System.out.println(ThreadLocalProcessor.studentThreadLocal.get().name);
    }

}

class SexService {

    public void getSex() {
        System.out.println(ThreadLocalProcessor.studentThreadLocal.get().sex);
    }

}

class ScoreService {

    public void getScore() {
        System.out.println(ThreadLocalProcessor.studentThreadLocal.get().score);
    }

}

上面的代码就省去了频繁的传递参数,也没有使用到锁机制,同样满足了需求,思想其实和上面将学生信息存储到Map中的思想差不多,只不过这里不是将学生信息存储到Map中,而是存储到了ThreadLocal中,原理图如下所示:

3

那么总结这两种用法,通常分别用在不同的场景里:

  • 场景一:通常多线程之间需要拥有同一个对象的副本,那么通常就采用initialValue()方法进行初始化,直接将需要拥有的对象存储到ThreadLocal中。
  • 场景二:如果多个线程中存储不同的信息,为了方便在其他方法里面获取到信息,那么这种场景适合使用set()方法。例如,在拦截器生成的用户信息,用ThreadLocal.set直接放入到ThreadLocal中去,以便在后续的方法中取出来使用。

三、理解ThreadLocal原理

3.1 理解ThreadLocalMap数据结构

通过本文的第二小节的介绍,相信大家基本上可以掌握ThreadLocal的基本使用方法,接下来,我们来一起阅读ThreadLocal源码,从源码角度来真正理解ThreadLocal。

在阅读源码之前,我们一起来看看一张图片:

4

上图中基本描述出了Thread、ThreadLocalMap以及ThreadLocal三者之间的包含关系。Thread类对象中维护了ThreadLocalMap成员变量,而ThreadLocalMap维护了以ThreadLocal为key,需要存储的数据为value的Entry数组。这是它们三者之间的基本包含关系,我们需要进一步到源码中寻找踪迹。

查看Thread类,内部维护了两个变量,threadLocals和inheritableThreadLocals,它们的默认值是null,它们的类型是ThreadLocal.ThreadLocalMap,也就是ThreadLocal类的一个静态内部类ThreadLocalMap。

在静态内部类ThreadLocalMap维护一个数据结构类型为Entry的数组,节点类型如下代码所示:

static class Entry extends WeakReference<ThreadLocal<?>> {
    /** The value associated with this ThreadLocal. */
    Object value;

    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}

从源码中我们可以看到,Entry结构实际上是继承了一个ThreadLocal类型的弱引用并将其作为key,value为Object类型。这里使用弱引用是否会产生问题,我们这里暂时不讨论,在文章结束的时候一起讨论一下,暂且可以理解key就是ThreadLocal对象。对于ThreadLocalMap,我们一起来了解一下其内部的变量:

// 默认的数组初始化容量
private static final int INITIAL_CAPACITY = 16;
// Entry数组,大小必须为2的幂
private Entry[] table;
// 数组内部元素个数
private int size = 0;
// 数组扩容阈值,默认为0,创建了ThreadLocalMap对象后会被重新设置
private int threshold;

这几个变量和HashMap中的变量十分类似,功能也类似。

ThreadLocalMap的构造方法如下所示:

/**
 * Construct a new map initially containing (firstKey, firstValue).
 * ThreadLocalMaps are constructed lazily, so we only create
 * one when we have at least one entry to put in it.
 */
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
    // 初始化Entry数组,大小 16
    table = new Entry[INITIAL_CAPACITY];
    // 用第一个键的哈希值对初始大小取模得到索引,和HashMap的位运算代替取模原理一样
    int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
    // 将Entry对象存入数组指定位置
    table[i] = new Entry(firstKey, firstValue);
    size = 1;
    // 初始化扩容阈值,第一次设置为10
    setThreshold(INITIAL_CAPACITY);
}

从构造方法的注释中可以了解到,该构造方法是懒加载的,只有当我们创建一个Entry对象并需要放入到Entry数组的时候才会去初始化Entry数组。

分析到这里,也许我们都有一个疑问,平常使用ThreadLocal功能都是借助ThreadLocal对象来操作的,比如set、get、remove等,使用上都屏蔽了ThreadLocalMap的API,那么到底是如何做到的呢?我们一起继续看下面的代码。

3.2 理解ThreadLocal类set方法

试想我们一个请求对应一个线程,我们可能需要在请求到达拦截器之后,可能需要校验当前请求的用户信息,那么校验通过的用户信息通常都放入到ThreadLocalMap中,以方便在后续的方法中直接从ThreadLocalMap中获取

但是我们并没有直接操作ThreadLocalMap来存取数据,而是通过一个静态的ThreadLocal变量来操作,我们从上面的图可以看出,ThreadLocalMap中存储的键其实就是ThreadLocal的弱引用所关联的对象,那么键是如何操作类似HashMap的值的呢?我们一起来分析一下set方法:

public void set(T value) {
    // 首先获取调用此方法的线程
    Thread t = Thread.currentThread();
    // 将线程传递到getMap方法中来获取ThreadLocalMap,其实就是获取到当前线程的成员变量threadLocals所指向的ThreadLocalMap对象
    ThreadLocalMap map = getMap(t);
    // 判断Map是否为空
    if (map != null)
        // 如果Map为不空,说明当前线程内部已经有ThreadLocalMap对象了,那么直接将本ThreadLocal对象作为键,存入的value作为值存储到ThreadLocalMap中
        map.set(this, value);
    else
        // 创建一个ThreadLocalMap对象并将值存入到该对象中,并赋值给当前线程的threadLocals成员变量
        createMap(t, value);
}

// 获取到当前线程的成员变量threadLocals所指向的ThreadLocalMap对象
ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}

// 创建一个ThreadLocalMap对象并将值存入到该对象中,并赋值给当前线程的threadLocals成员变量
void createMap(Thread t, T firstValue) {
    t.threadLocals = new ThreadLocalMap(this, firstValue);
}

上面的set方法是ThreadLocal的set方法,就是为了将指定的值存入到指定线程的threadLocals成员变量所指向的ThreadLocalMap对象中,那么具体是如何存取的,其实调用的还是ThreadLocalMap的set方法,源码分析如下所示:

private void set(ThreadLocal<?> key, Object value) {

    // We don't use a fast path as with get() because it is at
    // least as common to use set() to create new entries as
    // it is to replace existing ones, in which case, a fast
    // path would fail more often than not.

    Entry[] tab = table;
    int len = tab.length;
    // 计算当前ThreadLocal对象作为键在Entry数组中的下标索引
    int i = key.threadLocalHashCode & (len-1);

    // 线性遍历,首先获取到指定下标的Entry对象,如果不为空,则进入到for循环体内,
    // 判断当前的ThreadLocal对象是否是同一个对象,如果是,那么直接进行值替换,并结束方法,
    // 如果不是,再判断当前Entry的key是否失效,如果失效,则直接将失效的key和值进行替换。
    // 这两点都不满足的话,那么就调用nextIndex方法进行搜寻下一个合适的位置,进行同样的操作,
    // 直到找到某个位置,内部数据为空,也就是Entry为null,那么就直接将键值对设置到这个位置上。
    // 最后判断是否达到了扩容的条件,如果达到了,那么就进行扩容。
    for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
        ThreadLocal<?> k = e.get();

        if (k == key) {
            e.value = value;
            return;
        }

        if (k == null) {
            replaceStaleEntry(key, value, i);
            return;
        }
    }

    tab[i] = new Entry(key, value);
    int sz = ++size;
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}

这里的代码核心的地方就是for循环这一块,代码上面加了详细的注释,这里在复述一遍:

线性遍历,首先获取到指定下标的Entry对象,如果不为空,则进入到for循环体内,判断当前的ThreadLocal对象是否是同一个对象

如果是,那么直接进行值替换,并结束方法。如果不是,再判断当前Entry的key是否失效,如果失效,则直接将失效的key和值进行替换。

这两点都不满足的话,那么就调用nextIndex方法进行搜寻下一个合适的位置,进行同样的操作,直到找到某个位置,内部数据为空,也就是Entry为null,那么就直接将键值对设置到这个位置上。最后判断是否达到了扩容的条件,如果达到了,那么就进行扩容。

这里有两点需要注意:一是nextIndex方法,二是key失效,这里先解释第一个注意点,第二个注意点涉及到弱引用JVM GC问题,文章最后做出解释。

nextIndex方法的具体代码如下所示:

private static int nextIndex(int i, int len) {
    return ((i + 1 < len) ? i + 1 : 0);
}

其实就是寻找下一个合适位置,找到最后一个后还不合适的话,那么从数组头部重新开始找,且一定可以找到,因为存在扩容阈值,数组必定有冗余的位置存放当前键值对所对应的Entry对象。其实nextIndex方法就是大名鼎鼎的『开放寻址法』的应用。

这一点和HashMap不一样,HashMap存储HashEntry对象发生哈希冲突的时候采用的是链表方式进行存储,而这里是去寻找下一个合适的位置,思想就是『开放寻址法』。

3.3 理解ThreadLocal类get方法

在实际的开发中,我们往往需要在代码中调用ThreadLocal对象的get方法来获取存储在ThreadLocalMap中的数据,具体的源码如下所示:

public T get() {
    // 获取当前线程的ThreadLocalMap对象
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        // 如果map不为空,那么尝试获取Entry数组中以当前ThreadLocal对象为键的Entry对象
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            // 如果找到,那么直接返回value
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    // 如果Map为空或者在Entry数组中没有找到以当前ThreadLocal对象为键的Entry对象,
    // 那么就在这里进行值初始化,值初始化的过程是将null作为值,当前ThreadLocal对象作为键,
    // 存入到当前线程的ThreadLocalMap对象中
    return setInitialValue();
}

// 值初始化过程
private T setInitialValue() {
    T value = initialValue();
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
    return value;
}

值初始化过程是这样的一个过程,如果调用新的ThreadLocal对象的get方法,那么在当前线程的成员变量threadLocals中必定不存在key为当前ThreadLocal对象的Entry对象,那么这里值初始话就将此ThreadLocal对象作为key,null作为值存储到ThreadLocalMap的Entry数组中。

3.4 理解ThreadLocal的remove方法

使用ThreadLocal这个工具的时候,一般提倡使用完后及时清理存储在ThreadLocalMap中的值,防止内存泄露。这里一起来看下ThreadLocal的remove方法。

public void remove() {
    ThreadLocalMap m = getMap(Thread.currentThread());
    if (m != null)
        m.remove(this);
}

// 具体的删除指定的值,也是通过遍历寻找,找到就删除,找不到就算了
private void remove(ThreadLocal<?> key) {
    Entry[] tab = table;
    int len = tab.length;
    int i = key.threadLocalHashCode & (len-1);
    for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
        if (e.get() == key) {
            e.clear();
            expungeStaleEntry(i);
            return;
        }
    }
}

看了这么多ThreadLocal的源码实现,其实原理还是很简单的,基本上可以说是一看就懂,理解ThreadLocal原理,其实就是需要理清Thread、ThreadLocal、ThreadLocalMap三者之间的关系

这里加以总结:线程类Thread内部持有ThreadLocalMap的成员变量,而ThreadLocalMap是ThreadLocal的内部类,ThreadLocal操作了ThreadLocalMap对象内部的数据,对外暴露的都是ThreadLocal的方法API,隐藏了ThreadLocalMap的具体实现,理清了这一点,ThreadLocal就很容易理解了。

四、理解ThreadLocalMap内存泄露问题

这里所说的ThreadLocal的内存泄露问题,其实都是从ThreadLocalMap中的一段代码说起的,这段代码就是Entry的构造方法:

static class Entry extends WeakReference<ThreadLocal<?>> {
    /** The value associated with this ThreadLocal. */
    Object value;

    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}

这里简单介绍一下Java内的四大引用:

  • 强引用:Java中默认的引用类型,一个对象如果具有强引用那么只要这种引用还存在就不会被回收。比如String str = new String("Hello ThreadLocal");,其中str就是一个强引用,当然,一旦强引用出了其作用域,那么强引用随着方法弹出线程栈,那么它所指向的对象将在合适的时机被JVM垃圾收集器回收。
  • 软引用:如果一个对象具有软引用,在JVM发生内存溢出之前(即内存充足够使用),是不会GC这个对象的;只有到JVM内存不足的时候才会调用垃圾回收期回收掉这个对象。软引用和一个引用队列联合使用,如果软引用所引用的对象被回收之后,该引用就会加入到与之关联的引用队列中。
  • 弱引用:这里讨论ThreadLocalMap中的Entry类的重点,如果一个对象只具有弱引用,那么这个对象就会被垃圾回收器回收掉(被弱引用所引用的对象只能生存到下一次GC之前,当发生GC时候,无论当前内存是否足够,弱引用所引用的对象都会被回收掉)。弱引用也是和一个引用队列联合使用,如果弱引用的对象被垃圾回收期回收掉,JVM会将这个引用加入到与之关联的引用队列中。若引用的对象可以通过弱引用的get方法得到,当引用的对象被回收掉之后,再调用get方法就会返回null。
  • 虚引用:虚引用是所有引用中最弱的一种引用,其存在就是为了将关联虚引用的对象在被GC掉之后收到一个通知。

我们从ThreadLocal的内部静态类Entry的代码设计可知,ThreadLocal的引用k通过构造方法传递给了Entry类的父类WeakReference的构造方法,从这个层面来说,可以理解ThreadLocalMap中的键是ThreadLocal的所引用。

当一个线程调用ThreadLocal的set方法设置变量的时候,当前线程的ThreadLocalMap就会存放一个记录,这个记录的键为ThreadLocal的弱引用,value就是通过set设置的值,这个value值被强引用。

如果当前线程一直存在且没有调用该ThreadLocal的remove方法,如果这个时候别的地方还有对ThreadLocal的引用,那么当前线程中的ThreadLocalMap中会存在对ThreadLocal变量的引用和value对象的引用,是不会释放的,就会造成内存泄漏。

考虑这个ThreadLocal变量没有其他强依赖,如果当前线程还存在,由于线程的ThreadLocalMap里面的key是弱引用,所以当前线程的ThreadLocalMap里面的ThreadLocal变量的弱引用在垃圾回收的时候就被回收,但是对应的value还是存在的这就可能造成内存泄漏(因为这个时候ThreadLocalMap会存在key为null但是value不为null的entry项)。

总结:ThreadLocalMap中的Entry的key使用的是ThreadLocal对象的弱引用,在没有其他地方对ThreadLocal依赖,ThreadLocalMap中的ThreadLocal对象就会被回收掉,但是对应的值不会被回收,这个时候Map中就可能存在key为null但是值不为null的项,所以在使用ThreadLocal的时候要养成及时remove的习惯。

0

评论区