一文带你看懂Java多线程并发,深度剖析AQS源码


1.摘要
本问旨在通过例子来带领大家分析Java并发编程中线程不安全行为,并相应给出对应的解决方案。

1.线程基础知识

(1) 概念:cpu执行最基本单元
这里有必要额外解释下进程,进程是系统进行资源分配和调度的基本单元
通常来说一个应用程序占用一个进程,当然一个服务也可以开启多个进程。
一个进程至少包含一个线程,进程内所有线程共享进程资源
(2) 状态:
线程五种状态:分别是新建、就绪、运行、阻塞、死亡。
1.创建线程让线程来到新建状态。
2.调用start()方法,此时线程来到就绪状态。
3.CPU时间片轮训调度执行到该线程,此时线程处于运行状态。
4.线程执行sleep/ wait /join 方法,来到阻塞状态。
5.线程内run方法执行结束,线程凋亡。
在这里插入图片描述
(3) 分类:
线程分为用户线程和守护线程。用户线程则是用户创建普通线程,JVM启动从Main函数开始执行,这个主线程也是一个就是一个用户线程,其中JVM运行中还同时开启很多守护线程,比如低优先级的GC线程。
两者的区别:
守护线程会随着最后一个用户线程结束后一起结束。JVM在执行过程中,并不会因为守护线程结束而结束运行,相反,当用户线程/主线程执行结束,JVM则会直接退出,此时如果还有后台守护线程仍然在运行中,则直接一并结束运行。
(4) 线程创建几种方式:
1) 类继承Thread 类,重写run方法
2) 类实现runnable 接口,重写run方法
3) 类实现 callabled 接口,重写call方法,并结合FutureTask 获取返回值。【这个方法可以带返回值
4) 使用Executors 构建线程池。前面三种都是创建单个线程的方式。
(5) 多线程并发编程的意义:
随着互联网数据和访问请求流量的日益增加,单核CPU /单线程处理任务的效率已经无法满足人们都系统响应的要求,且伴随着多核CPU时代的到来,多线程并发处理任务,能同时利用多个CPU同时并发处理海量的系统请求,极大的提升系统性能。

2.线程安全举措

(1) 原生不可变类【使用final修饰的类】
(2) 使用ThreadLocal 来存储对象,ThreadLocal 对象属于线程私有,每个对象只会存储对象的一个副本,线程在操作该对象时,只会操作当前线程内部的对象,从而做到数据隔离,线程安全。
(3) volatile/synchronized 关键字
volatile: 只能解决有序性可见性。不能保证原子性。通常用来解决多线程情况下,每次需要获取对象最新数据。

有序性
Java内存模型允许编译器和处理器对指令进行重排需从而获取更优越的性能。但是这个只是会对不存在依赖性的指令发生重排序。单线程下不会出现问题,多线程情况下有可能就是因为执行重排而导致执行异常。因此对于多线程情况下,某些变量写操作不依赖当前变量的值,且需要获取最新数据时,可以使用volatile 关键字进行声明,避免指令重排,且维持可见性。

可见性:线程在获取当前对象的值总是从主内存获取最新的数据,而不是读取CPU和主内存之间的缓存,可以有效解决数据未实时同步更新的情况
缓存扩展补充:
为了解决主内存与CPU 之间运行速度之间差,或者说为了进一步提升CPU处理数据的速度,通常会在主内存和CPU之间使用多级缓存用以存储数据,CPU执行时,直接从缓存中取存数据,然后缓存在将数据同步更新至主内存中去。
L1、 L2、 L3 每一级缓存存储数据空间更大,同时缓存速度依次下降。由于CPU执行不是以变量来加载执行的,而是以Cache行为单位与主内存进行数据交换的,一般是2的幂次数字节,因此当多个变量存在一个Cache行中,多线程同时修改一个Cache行里面的多个变量时,由于同一时刻只能存在一个线程操作缓存行,根据缓存一致性协议,相比将多个变量存在不同Cache行进行操作,性能会有所下降,这就是所谓的伪共享
如何避免伪共享JDK8之前都是通过字节填充的方式来避免,就是说创建一个变量时使用填充字段来填充该变量所在的缓存行,避免将多个变量存放在同一个缓存行中
JDK8提供了一个注解来解决伪共享问题。@sun.misc.Contended. 可作用于类和对象上,默认只能用于Java核心类,用户类路径下需要手动添加JVM参数开启。
-XX:-RestrictContended 【开启注解】
-XX:ContendedPaddingWidth 【填充宽度默认128可设置】

在这里插入图片描述

(4) 原子类/CAS
JDK内置AtomicLongAtomicIntegerLongAdderLongAccumulator
CAS 操作通过CPU原语实现,是一种硬件实现线程安全方式,其中可以使用Unsafe类来给对象定制化CAS操作,谨慎使用Unsafe类,这个类可以直接操作内存。【Unsafe类无法直接实例化(JDK大佬对其做了限制,不允许直接操作,底层就是做了一层判断,判断当前类加载器是不是启动类加载器,不是的话,直接抛异常。开发通常使用的加载器就是应用程序加载器),但是可以借助反射技术来实例化】
(5) 锁

  1. synchronized关键字实现同步
    JVM 内置锁,底层依赖于互斥锁MuteLock,线程获取锁,会相应进入MonitorEnter,退出则进入MoniterExit ,内置使用state维护锁状态。
  2. 各种显示锁如 ReentrantLock、ReentrantReadWriteLock、JDK8新增StampedLock等。底层都是基于AQS实现。
    下面通过ReentrantLock来认识AQS。AQS是实现同步器的抽象组件JUC包所有锁的底层就是用的AQS。
    ReentrantLock有个Sync类直接继承AQS。内部四个关键对象分别是
    head 头指针,tail尾指针,exclusiveOwnerThread= threadName 当前独占锁所属线程。state 表示获取锁次数。其中state从0变成1 时候,表示当前有线程获取锁,如果此时当前线程继续获取锁,则state则会执行state=state+1 操作。【该锁是可重入独占锁,允许获取锁线程重复获取锁】
    整个流程图如下:
    在这里插入图片描述
    这个是默认非公平锁实现。

下面着重分析入队列方法enq(final Node node)公平锁tryAcquire() 方法

private Node enq(final Node node) {
  for (;;) {
    Node t = tail;
    if (t == null) { // Must initialize
      if (compareAndSetHead(new Node())) 
       tail = head;
    } else {
      node.prev = t;
      if (compareAndSetTail(t, node)) {
        t.next = node;
        return t;
      }
    }
  }
 }

第一次压入队列执行逻辑图: 后续Node 对象入队列,只会在队列尾部添加,以双向链表形式构建,并且设置tail指针指向当前Node对象。
在这里插入图片描述
公平锁和非公平锁最大的区别就在tryAcquire()方法的实现。JDK21为例子进行说明

        /**
         * Acquires only if thread is first waiter or empty
         */
        protected final boolean tryAcquire(int acquires) {
            if (getState() == 0 && !hasQueuedPredecessors() &&
                compareAndSetState(0, acquires)) {
                setExclusiveOwnerThread(Thread.currentThread());
                return true;
            }
            return false;
        }
	// 公平锁补充判断逻辑 !hasQueuedPredecessors
	/*	前面注释省略
	 * @return {@code true} if there is a queued thread preceding the
     *         current thread, and {@code false} if the current thread
     *         is at the head of the queue or the queue is empty
     * @since 1.7
     */
     // 如果队列存在当前线程且当前线程是队列头部或者队列为空 则允许放行获取竞争锁
    public final boolean hasQueuedPredecessors() {
        Thread first = null; Node h, s;
        // 如果头节点不为空且 
        //头指针下一个节点为空【为真 则说明正在安排插入第一个线程入队列】 或者
        // 第一个节点waiter 线程为null 【为真 则正在安排插入第一个线程入队列 】或者
        // 第一个node节点的pre ==null 【为真 则正在安排插入第一个线程入队列】。
        // 如果以上都不满足,那么第一个node节点早已经完成入队操作。 
        if ((h = head) != null && ((s = h.next) == null ||
                                   (first = s.waiter) == null ||
                                   s.prev == null))
            first = getFirstQueuedThread(); // retry via getFirstQueuedThread
        // 在以上情况都不满足的情况下, 如果first 和当前线程不一致,那么就是返回true。
        // 那么将不参与获取锁竞争。tryAcquire() 方法判断逻辑是  !hasQueuedPredecessors()
        return first != null && first != Thread.currentThread();
    }


    // 获取队列节点的第一个线程
    public final Thread getFirstQueuedThread() {
        Thread first = null, w; Node h, s;
        if ((h = head) != null && ((s = h.next) == null ||
                                   (first = s.waiter) == null ||
                                   s.prev == null)) {
            // traverse from tail on stale reads
            //  从尾部tail 节点开始读,反向读,并且每次读都将上一个非null节点赋值给当前节点
            //  直到p的上一个节点null结束也就是指定到第一个node后,
            //  下一次循环就不符合条件,不在执行了。p.waiter就是第一个node节点的线程
            for (Node p = tail, q; p != null && (q = p.prev) != null; p = q)
                if ((w = p.waiter) != null)
                    first = w;
        }
        return first;
    }

这个第一个节点Node属性waiter设置为null。
在这里插入图片描述
条件队列和同步队列:
AQS 内部采用FIFO(先进先出)基于双向链表实现的同步队列,用于阻塞排队获取锁,同时也提供了基于单向链表实现的条件队列,用以条件控制等待获取锁。下面结合图进行介绍。
其中条件队列可以有多个,当锁Contion对象调用await 方法,会将当前线程释放锁,追加至条件队列。当锁Condition对象调用singnal或者singalAll(唤醒所有线程)方法唤醒线程,将条件队列线程追加至同步队列尾部。

在这里插入图片描述

多线程操作实例源码 Windows是一个多任务的系统,如果你使用的是windows 2000及其以上版本,你可以通过任务管理器查看当前系统运行的程序和进程。什么是进程呢?当一个程序开始运行时,它就是一个进程,进程所指包括运行中的程序和程序所使用到的内存和系统资源。而一个进程又是由多个线程所组成的,线程是程序中的一个执行流,每个线程都有自己的专有寄存器(栈指针、程序计数器等),但代码区是共享的,即不同的线程可以执行同样的函数。多线程是指程序中包含多个执行流,即在一个程序中可以同时运行多个不同的线程来执行不同的任务,也就是说允许单个程序创建多个并行执行的线程来完成各自的任务。浏览器就是一个很好的多线程的例子,在浏览器中你可以在下载JAVA小应用程序或图象的同时滚动页面,在访问新页面时,播放动画和声音,打印文件等。   多线程的好处在于可以提高CPU的利用率——任何一个程序员都不希望自己的程序很多时候没事可干,在多线程程序中,一个线程必须等待的时候,CPU可以运行其它的线程而不是等待,这样就大大提高了程序的效率。   然而我们也必须认识到线程本身可能影响系统性能的不利方面,以正确使用线程: 线程也是程序,所以线程需要占用内存,线程越多占用内存也越多 多线程需要协调和管理,所以需要CPU时间跟踪线程 线程之间对共享资源的访问会相互影响,必须解决竞用共享资源的问题 线程太多会导致控制太复杂,最终可能造成很多Bug   基于以上认识,我们可以一个比喻来加深理解。假设有一个公司,公司里有很多各司其职的职员,那么我们可以认为这个正常运作的公司就是一个进程,而公司里的职员就是线程。一个公司至少得有一个职员吧,同理,一个进程至少包含一个线程。在公司里,你可以一个职员干所有的事,但是效率很显然是高不起来的,一个人的公司也不可能做大;一个程序中也可以只用一个线程去做事,事实上,一些过时的语言如fortune,basic都是如此,但是象一个人的公司一样,效率很低,如果做大程序,效率更低——事实上现在几乎没有单线程的商业软件。公司的职员越多,老板就得发越多的薪水给他们,还得耗费大量精力去管理他们,协调他们之间的矛盾和利益;程序也是如此,线程越多耗费的资源也越多,需要CPU时间去跟踪线程,还得解决诸如死锁,同步等问题。总之,如果你不想你的公司被称为“皮包公司”,你就得多几个员工;如果你不想让你的程序显得稚气,就在你的程序里引入多线程吧!
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值