Java多线程并发杂谈

2 篇文章 0 订阅
2 篇文章 0 订阅


在同一程序中运行多个线程本身不会导致问题,问题在于多个线程同时访问临界区中相同的资源时,结果就会变得不可预测,由于涉及了数据可见性,操作的时序性,就会产生多线程并发的问题。

0x000 多线程编程中的三个核心概念

  1. 原子性:跟数据库事务的原子性概念差不多,即一个操作要么全部执行,要么全部都不执行。
  2. 可见性:多个线程并发访问共享变量时,一个线程对共享变量的修改,其它线程能够立即看到。
  3. 顺序性:程序执行的顺序按照代码的先后顺序执行。(CPU为了优化程序执行速度,在保证程序最终的执行结果和代码顺序执行时的结果一致情况下,会改变程序执行顺序,就是指令重排序)

0x001 JMM内存模型

jmm屏蔽了底层平台的内存管理细节,目的是定义各个变量(实例变量、类变量、数组对象元素,不含有局部变量,因为线程私有)的访问规则。

  • Java线程间通信是通过共享变量来进行的
  • 所有的共享变量(实例变量、类变量、数组对象元素,不含有局部变量,因为线程私有)都存储在主内存
  • JMM规定了jvm有主内存(堆,线程共享)和工作内存(线程私有)
  • 线程对变量的所有操作(读取,赋值)必须在工作内存中进行,不能直接读写主内存的变量
    1. 复制变量:主内存–>工作内存(read load)
    2. 使用变量:工作内存(use assign)
    3. 刷新变量:工作内存–>主内存(store write)

a. 有了工作内存,线程间的工作内存就有了相互隔离的效果,术语就称之为可见性问题
b. 当线程执行完read laod操作时,会引用该变量副本;此时如果该线程再一次引用该字段,就有两种选择,重新从主内存复制变量(read load use)和直接使用已有变量副本(use),如何选择由jvm通过定义的8大内存交换规则决定;而这个时候线程间的先后顺序,会对程序的最终结果产生影响,术语称之为时序性问题

0x001 什么是线程不安全

多个线程同时操作一个共享变量时发生了相互修改和串行的情况,没有保证数据一致性,导致结果不可预测,称这样设计的代码为线程不安全。

0x010 Java解决多线程并发问题

a. Java保证原子性

有三种方式,锁Lock、同步Synchronized和CAS(compare and swap)

1. 显式锁Lock方式

相比于内置锁,显示锁与之有如下不同。

  • 显式锁的加解锁都需要手动调用
  • 内置锁唤醒和等待是通过Object的方法wait/notify/notifyAll进行的,同时内置锁无法实现精准唤醒
  • 显式锁唤醒和等待是用Condition的await/signal/signalAll,同时由于显式锁可以关联多个condition对象,因此可实现精准唤醒
2. 内置锁,同步Synchronized方式

synchronized是一种同步锁,同时也是一种悲观锁。采用synchronized修饰符实现的同步机制叫做互斥锁机制,它所获得的锁叫做互斥锁。每个对象都有一个monitor(锁标记),当线程拥有这个锁标记时才能访问这个资源,没有锁标记便进入锁池。任何一个对象系统都会为其创建一个互斥锁,这个锁是为了分配给线程的,防止打断原子操作。每个对象的锁只能分配给一个线程,因此叫做互斥锁。


同步代码块


同步代码块字节码


同步方法


同步方法字节码

另外,在同一个对象实例中,有多个synchronized修饰的普通方法,多个线程同时访问这些方法(同一个对象实例中),在同一时间内,只能有一个线程能够取得该对象的锁并执行程序,因为在同步方法中,只有获取了锁才能执行方法,而一个对象只有一个锁

对象在内存中的存储布局,分为3个区域:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。
Header:包含两部分1.Mark Word,2.类型指针,即指向对象的类元数据的指针。

  • 锁级别从低到高依次为:无状态锁,偏向锁,轻量级锁和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级。

3. CAS(compare and swap)

CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。CAS是一种非阻塞方法,现代的CPU提供了特殊的指令,可以自动更新共享数据,而且能够检测到其他线程的干扰,而 compareAndSet() 就用这些代替了锁定。

  • 拿AtomicInteger来研究在没有锁的情况下是如何做到数据正确性的。
  • 首先需要使用volatile来保证可见性。private volatile int value;
    public int casAdd(){
        while(true){
            int current = state;
            if(compareAndSet(current,current + 1)){//拿current的值A和主内存值V比较
                return current + 1;
            }
        }
  • CAS是一种乐观锁,如果未成功就一直循环,直到成功
  • CAS存在ABA问题,可以通过为变量增加版本号来解决AtomicStampedReference。
  • CAS循环开销大
  • CAS只能保证一个共享变量的原子操作,使用AtomicRefefence存储多个共享变量的方式来解决。

b. Java保证可见性

通过volatie关键字保证。当用volatile修饰某个变量时,这个变量的任何修改会被立即刷新到主内存中,同时其他线程中对该变量的缓存行会被jvm置为无效,因此其它线程需要读取该值时必须从主内存中读取,从而得到最新的值。

    //该单例模式 双重检锁一定安全么?
    public class Sington{
        public static Sington instance;
        private Sington(){}

        public static Sington getInstance(){
            if(instance == null){
                synchronized(Sington.class){
                    if(instance == null){
                        instance = new Sington();
                    }
                }
            }
            return instance;
        }
    }

answer

c. Java保证顺序性

按照对开发中代码的相关程度,依次有以下方式。

  1. synchronized同步和锁保证顺序性和保证原子性一样,都是通过排他的方式来实现。
  2. volatile在一定程度上能够保证顺序性。(volatile另一层含义是禁止指令重排序)
  3. happens-before(先行发生原则),两个操作的执行顺序只要可以通过happens-before推导出来,则JVM会保证其顺序性,反之JVM对其顺序性不作任何保证,可对其进行任意必要的重新排序以获取高效率。
    • 程序次序规则(Program Order):在同一线程内,写在前面的操作先行发生于之后的操作。
    • 管程锁定规则(Monitor Lock):一个unlock操作先行发生于时间上后面对同一个锁的lock操作。
    • volatile变量规则(Volatile Variable):volatile变量的写操作先行发生于时间上后面对该变量的读操作。
    • 线程启动规则(Thread Start):Thread对象的start方法先行发生于线程的每一个动作。
    • 线程终止规则(Thread Termination):线程中所有的操作先行发生有线程的终止检测。
    • 线程中断规则(Thread Interruption):对线程的interrupt方法调用先行发生于被中断线程的代码检测到该中断事件的发生。
    • 对象终止原则(Finalizer):一个对象的初始化完成先行发生于finalize方法的开始。
    • 传递性(Transitivity)

看一个重排序的例子:

0x011 AQS框架

1. 概述

Java并发工具包JUC提供了许多并发工具,如常见的ReentrantLock可重入锁,Semaphore信号量等,它们都有一个共同的父类AbstractQueuedSynchronizer,AQS框架用来构建锁和同步器,该框架底层使用了CAS。


JUC并发工具包整体架构

2. 基本原理

  • AQS类中持有一个volatile的变量state,用来表示同步状态。
  • 图中三个方法用来操作同步状态。

其中,AQS支持两种同步方式,独占式和共享式

  1. 在这里额外说明一点,AbstractQueuedSynchronizer虽然被声明为abstract,但是类中没有任何一个抽象方法,声明为abstract仅仅是为了使得AQS方法无法被实例化,因为上图各个框中的方法默认实现都是直接抛出异常。
  2. 有人可能会疑惑,为什么要用继承而不用接口实现呢,不是说面向接口编程么,其实是框架的作者站在了开发者的角度来考虑的,如果采用接口的方式,那么开发者在实现自定义同步器的时候,比如要实现一个独占式的同步器,那么他也要实现共享式的方法,这样会给开发者带来疑惑,而采用继承的方式,加上模板方法的使用,就可以在子类中只选择重写需要的方法即可。

3. 获取同步状态

   public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }

以独占式acquire方法为例。主要步骤如下。

  1. 调用子类复写的tryAcquire方法,如果成功直接返回。否则执行步骤2.
  2. 否则,调用addWaiter方法将新增的EXCLUSIVE同步节点加入同步队列的尾部。
  3. 该节点在同步队列尝试获取同步状态,若获取不到,则阻塞结点线程,直到被前驱结点唤醒或者被中断。



    private Node addWaiter(Node mode) {
        //构建新节点,第一个参数表示nextWaiter,
        Node node = new Node(Thread.currentThread(), mode);
        Node pred = tail;
        if (pred != null) {
        //尾节点不为空,直接cas添加
            node.prev = pred;
            if (compareAndSetTail(pred, node)) {
                pred.next = node;
                return node;
            }
        }
        //尾节点为空,进入enq入队列方法
        enq(node);
        return node;
    }

        private Node enq(final Node node) {
        //cas保证正确同步
        for (;;) {
            Node t = tail;
            if (t == null) { // Must initialize
                if (compareAndSetHead(new Node()))
                    tail = head;
            } else {
            //有可能多个线程进入enc方法,所以需要该步骤
                node.prev = t;
                if (compareAndSetTail(t, node)) {
                    t.next = node;
                    return t;
                }
            }
        }

回头看acquireQuequd请求同步队列方法

    final boolean acquireQueued(final Node node, int arg) {
        boolean failed = true;
        try {
            boolean interrupted = false;
            for (;;) {
                final Node p = node.predecessor();//p为node的前驱节点
                if (p == head && tryAcquire(arg)) {//只有p为头节点才进行尝试获取同步方法
                    setHead(node);//获取同步状态成功,设置当前节点为头节点
                    p.next = null; // 显式置为null,GC发生时回收,(因为成功获得了同步状态,所以p已经可被回收了)
                    failed = false;
                    return interrupted;
                    //在整个等待过程中被中断过,则返回true,否则返回false
                }
                //如果p不是头节点,或者没有获取到同步状态
                if (shouldParkAfterFailedAcquire(p, node) &&//判断是否需要阻塞
                    parkAndCheckInterrupt())//进行阻塞
                    interrupted = true;
            }
        } finally {
            if (failed)//如果失败,取消
                cancelAcquire(node);
        }
    }

再看release方法

 private void unparkSuccessor(Node node) {
      int ws = node.waitStatus;
      if (ws < 0)//置零
          compareAndSetWaitStatus(node, ws, 0);
      Node s = node.next;//找到下一个需要唤醒的结点s
      if (s == null || s.waitStatus > 0) {//如果为空或已取消
          s = null;
         for (Node t = tail; t != null && t != node; t = t.prev)
             if (t.waitStatus <= 0)//从后向前找第一个状态不为取消的节点
                 s = t;
     }
     if (s != null)
         LockSupport.unpark(s.thread);//唤醒
 }

0x100 线程的中断

Java中断机制是一种协作机制,也就是说通过中断并不能直接终止另一个线程,而需要被中断的线程自己处理中断。这好比是家里的父母叮嘱在外的子女要注意身体,但子女是否注意身体,怎么注意身体则完全取决于自己。
Java中断模型也是这么简单,每个线程对象里都有一个boolean类型的标识(不一定就要是Thread类的字段,实际上也的确不是,这几个方法最终都是通过native方法来完成的),代表着是否有中断请求(该请求可以来自所有线程,包括被中断的线程本身)。例如,当线程t1想中断线程t2,只需要在线程t1中将线程t2对象的中断标识置为true,然后线程2可以选择在合适的时候处理该中断请求,甚至可以不理会该请求,就像这个线程没有被中断一样。

interrupt()方法是唯一能将中断状态设置为true的方法。
API中关于interrupt()方法的说明:

当前状态结果备注
阻塞在wait,sleep,join清除中断标志,抛出InterruptedException异常检测不到中断标志
阻塞在InterruptibleChannel上chanel关闭,设置中断标志,线程抛出ClosedByInterruptException异常有中断标志
阻塞在Selector中设置中断标志,线程立刻返回有中断标志
其他情况如何处理你自己决定有中断标志

  • Java的异常在线程之间不是共享的,在线程中抛出的异常是线程自己的异常,主线程并不能捕获到。
  • 对于运行时异常,使用UncaughtExceptionHandler()对未捕获的异常进行统一处理。

0x101 join关键字

join的作用是让主线程等待子线程结束后再执行。

0x110 等待/通知机制

每个锁对象都有两个队列,一个是同步队列,一个是等待队列。同步队列存储了已就绪(将要竞争锁)的线程,等待队列存储了被阻塞的线程。当一个阻塞线程被唤醒后,才会进入同步队列,进而等待CPU的调度;反之,当一个线程被wait后,就会进入等待队列,等待被唤醒。唤醒只意味着进入了同步队列,不意味着一定能获得资源。

0x111 线程池

线程的创建和切换开销是很大的,合理的使用线程池能够带来3个很明显的好处:
1.降低资源开销:可以重用已创建的线程来降低线程创建和销毁的开销
2.提高响应速度:不需要等待线程创建就可以立即执行
3.线程统一关联:线程池可以统一管理、分配、调优和监控


线程池继承关系图


线程池调度流程

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值