您当前的位置:首页 > 电脑百科 > 程序开发 > 编程百科

深入理解与应用多线程技术

时间:2024-01-09 14:53:27  来源:微信公众号  作者:一安未来
如果synchronized​作用于代码块,反编译可以看到两个指令:monitorenter、monitorexit,JVM​使用monitorenter和monitorexit​两个指令实现同步;如果作用synchronized​作用于方法,反编译可以看到ACCSYNCHRONIZED​标记,JVM​通过在方法访问标识符(flags​)中加入ACCSYNCHRONIZED来实现同步功能。

为什么要使用多线程

  1. 提高响应速度:对于耗时操作,使用线程可以避免阻塞主线程,提高应用程序的响应速度。
  2. 实现并行操作:在多CPU系统中,使用线程可以并行处理任务,提高CPU利用率。
  3. 改善程序结构:将一个既长又复杂的进程分为多个线程,可以使其成为几个独立或半独立的运行部分,这样有利于程序的修改和理解。
  4. 方便的通信机制:线程间可以通过共享内存等方式进行通信,比进程间通信更方便、高效。

创建线程有几种方式?

创建线程有四种方式:

  1. 通过继承Thread类来创建线程。
  2. 通过实现Runnable接口来创建线程。
  3. 通过实现Callable接口来创建线程。
  4. 使用Executor框架来创建线程池。

简单实现

public class ThreadTest {

    public static void mAIn(String[] args) {
        Thread thread = new MyThread();
        thread.start();
    }
}

class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("关注公众号:一安未来");
    }
}
 
public class ThreadTest {

    public static void main(String[] args) {
        MyRunnable myRunnable = new MyRunnable();
        Thread thread = new Thread(myRunnable);
        thread.start();
    }
}

class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("关注公众号:一安未来");
    }
}
 
public class ThreadTest {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        MyThreadCallable mc = new MyThreadCallable();
        FutureTask<Integer> ft = new FutureTask<>(mc);
        Thread thread = new Thread(ft);
        thread.start();
        System.out.println(ft.get());
    }
}

class MyThreadCallable implements Callable {
    @Override
    public String call()throws Exception {
        return "关注公众号:一安未来";
    }
}
public class ThreadTest {

    public static void main(String[] args) throws Exception {

        ThreadPoolExecutor executorOne = new ThreadPoolExecutor(5, 5, 1,
                TimeUnit.MINUTES, new ArrayBlockingQueue<Runnable>(20), new CustomizableThreadFactory("Yian-Thread-pool"));
        executorOne.execute(() -> {
            System.out.println("关注公众号:一安未来");
        });

        //关闭线程池
        executorOne.shutdown();
    }
}

线程和进程的区别

线程和进程是操作系统中重要的概念,都是操作系统资源分配的基本单位,但它们有一些关键的区别。

  1. 地址空间和资源拥有:进程是执行中的一个程序,具有自己的地址空间和文件描述符等资源。线程是在进程中执行的一个单独的执行路径,共享进程的地址空间和资源。
  2. 开销:创建和销毁一个进程需要保存寄存器、栈信息以及进行资源分配和回收等操作,开销较大。而线程的创建和销毁只需保存寄存器和栈信息,开销较小。
  3. 通信切换:进程之间必须通过IPC(进程间通信)进行通信,切换开销相对较大。线程之间可以直接共享进程的地址空间和资源,切换开销相对较小。
  4. 并发性:进程是独立的执行单元,具有自己的调度算法,在并发条件下更加稳定可靠。而线程共享进程的资源,线程之间的调度和同步比较复杂,对并发条件的处理需要更多的注意。
  5. 一对多的关系:一个线程只能属于一个进程,而一个进程可以拥有多个线程。

Runnable和 Callable有什么区别

  • Runnable接口只有一个需要实现的方法,即run()。当你启动一个线程时,这个run()方法就会被执行。Runnable的主要问题是它不支持返回结果
  • Callable可以返回结果,也可以抛出异常。它有一个call()方法,当调用这个方法时,这个方法就会被执行。

volatile作用,原理

主要用于声明变量,以指示该变量可能会被多个线程同时访问,从而防止编译器进行一些优化,确保线程之间能够正确地读写共享变量。volatile 提供了一种轻量级的同步机制,但它并不能替代 synchronized,因为它无法解决复合操作的原子性问题。

 

作用:

  • 可见性: 当一个线程修改了一个被 volatile 修饰的变量的值,其他线程能够立即看到这个修改,即保证了变量的可见性。
  • 禁止指令重排序: volatile 修饰的变量的读写操作会禁止指令重排序,确保变量的写操作不会被重排序到其它操作之前。

原理:

volatile 的实现原理涉及到 CPU 的缓存一致性和内存屏障(Memory Barrier)的概念。

  • 内存可见性: 当一个线程写入一个 volatile 变量时,会强制将该线程对应的本地内存中的值刷新到主内存中,从而保证了其他线程能够看到最新的值。同样,当一个线程读取一个 volatile 变量时,会强制从主内存中读取最新的值到本地内存中。
  • 禁止指令重排序: volatile 修饰的变量的读写操作会在其前后插入内存屏障,防止在其前后的指令被重排序。

synchronized 的实现原理以及锁优化

如果synchronized作用于代码块,反编译可以看到两个指令:monitorenter、monitorexit,JVM使用monitorenter和monitorexit两个指令实现同步;如果作用synchronized作用于方法,反编译可以看到ACCSYNCHRONIZED标记,JVM通过在方法访问标识符(flags)中加入ACCSYNCHRONIZED来实现同步功能。

  • 同步代码块,当线程执行到monitorenter的时候要先获得monitor锁,才能执行后面的方法。当线程执行到monitorexit的时候则要释放锁。
  • 同步方法,当线程执行有ACCSYNCHRONI标志的方法,需要获得monitor锁。每个对象都与一个monitor相关联,线程可以占有或者释放monitor。

monitor监视器

操作系统的管程(monitors)是概念原理,ObjectMonitor是它的原理实现。

图片图片

JAVA虚拟机(HotSpot)中,Monitor(管程)是由ObjectMonitor实现的,其主要数据结构如下:

ObjectMonitor() {
    _header       = NULL;
    _count        = 0; // 记录个数
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL;  // 处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ;  // 处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

Java Monitor 的工作机理

图片图片

  • 要获取monitor的线程,首先会进入EntryList队列。
  • 当某个线程获取到对象的monitor后,进入Owner区域,设置为当前线程,同时计数器count加1。
  • 如果线程调用了wait()方法,则会进入WaitSet队列阻塞等待。它会释放monitor锁,即将owner赋值为null,count自减1。
  • 如果其他线程调用 notify()/notifyAll() ,会唤醒WaitSet中的某个或全部线程,该线程再次尝试获取monitor锁,成功即进入Owner区域。
  • 同步方法执行完毕了,线程退出临界区,会将monitor的owner设为null,并释放监视锁

对象与monitor关联

图片图片

  • 在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header),实例数据(Instance Data)和对象填充(Padding)。
  • 对象头主要包括两部分数据:Mark word(标记字段)、Class Pointer(类型指针)。

Mark Word 是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。

图片图片

重量级锁,指向互斥量的指针。其实synchronized是重量级锁,也就是说Synchronized的对象锁,Mark Word锁标识位为10,其中指针指向的是Monitor对象的起始地址。

 

在JDK1.6之前,synchronized的实现直接调用ObjectMonitor的enter和exit,这种锁被称之为重量级锁。从JDK6开始,HotSpot虚拟机开发团队对Java中的锁进行优化,如增加了适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁等优化策略,提升了synchronized的性能。

  • 偏向锁:在无竞争的情况下,只是在Mark Word里存储当前线程指针,CAS操作都不做。
  • 轻量级锁:在没有多线程竞争时,相对重量级锁,减少操作系统互斥量带来的性能消耗。但是,如果存在锁竞争,除了互斥量本身开销,还额外有CAS操作的开销。
  • 自旋锁:减少不必要的CPU上下文切换。在轻量级锁升级为重量级锁时,就使用了自旋加锁的方式
  • 锁粗化:将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。
  • 锁消除:虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。

线程有哪些状态

图片图片

  • New:线程对象创建之后、但还没有调用start()方法,就是这个状态。
  • Runnable:它包括就绪(ready)和运行中(running)两种状态。如果调用start方法,线程就会进入Runnable状态。它表示我这个线程可以被执行啦(此时相当于ready状态),如果这个线程被调度器分配了CPU时间,那么就可以被执行(此时处于running状态)。
  • Blocked:阻塞的(被同步锁或者IO锁阻塞)。表示线程阻塞于锁,线程阻塞在进入synchronized关键字修饰的方法或代码块(等待获取锁)时的状态。比如前面有一个临界区的代码需要执行,那么线程就需要等待,它就会进入这个状态。它一般是从RUNNABLE状态转化过来的。如果线程获取到锁,它将变成RUNNABLE状态。
  • WAITING : 永久等待状态,进入该状态的线程需要等待其他线程做出一些特定动作(比如通知)。处于该状态的线程不会被分配CPU执行时间,它们要等待被显式地唤醒,否则会处于无限期等待的状态。一般Object.wait。
  • TIMED_WATING: 等待指定的时间重新被唤醒的状态。有一个计时器在里面计算的,最常见就是使用Thread.sleep方法触发,触发后,线程就进入了Timed_waiting状态,随后会由计时器触发,再进入Runnable状态。
  • 终止(TERMINATED):表示该线程已经执行完成。

CountDownLatch与CyclicBarrier 区别

CountDownLatch和CyclicBarrier都用于让线程等待,达到一定条件时再运行。主要区别是:

  • CountDownLatch:一个或者多个线程,等待其他多个线程完成某件事情之后才能执行;
  • CyclicBarrier:多个线程互相等待,直到到达同一个同步点,再继续一起执行。

图片图片

多线程环境下的伪共享

 

CPU的缓存是以缓存行(cache line)为单位进行缓存的,当多个线程修改相互独立的变量,而这些变量又处于同一个缓存行时就会影响彼此的性能。这就是伪共享

现代计算机计算模型:

图片图片

  • CPU执行速度比内存速度快好几个数量级,为了提高执行效率,现代计算机模型演变出CPU、缓存(L1,L2,L3),内存的模型。
  • CPU执行运算时,如先从L1缓存查询数据,找不到再去L2缓存找,依次类推,直到在内存获取到数据。
  • 为了避免频繁从内存获取数据,聪明的科学家设计出缓存行,缓存行大小为64字节。

也正是因为缓存行的存在,就导致了伪共享问题,如图所示:

图片图片

假设数据a、b被加载到同一个缓存行。

  • 当线程1修改了a的值,这时候CPU1就会通知其他CPU核,当前缓存行(Cache line)已经失效。
  • 这时候,如果线程2发起修改b,因为缓存行已经失效了,所以「core2 这时会重新从主内存中读取该 Cache line 数据」。读完后,因为它要修改b的值,那么CPU2就通知其他CPU核,当前缓存行(Cache line)又已经失效。
  • 所以,如果同一个Cache line的内容被多个线程读写,就很容易产生相互竞争,频繁回写主内存,会大大降低性能。

解决伪共享问题的一种方法是通过填充(Padding)来确保共享的变量独立存储于不同的缓存行中。填充的思想是在变量之间插入一些无关的数据,使它们分布到不同的缓存行,从而避免多个变量共享同一个缓存行。

在Java中,可以使用@Contended注解来避免伪共享。这个注解可以在字段上使用,它会在字段的前后插入填充,使得字段单独占据一个缓存行。

Fork/Join框架

Fork/Join框架是Java7提供的一个用于并行执行任务的框架,是一个把大任务分割成若干个小任务,最终汇总每个小任务结果后得到大任务结果的框架。

Fork/Join框架需要理解两个点,「分而治之」和「工作窃取」。

分而治之

图片图片

工作窃取

图片图片

一般就是指做得快的线程(盗窃线程)抢慢的线程的任务来做,同时为了减少锁竞争,通常使用双端队列,即快线程和慢线程各在一端。

ThreadLocal原理

ThreadLocal的内存结构图:

图片图片

  • Thread线程类有一个类型为ThreadLocal.ThreadLocalMap的实例变量threadLocals,即每个线程都有一个属于自己的ThreadLocalMap。
  • ThreadLocalMap内部维护着Entry数组,每个Entry代表一个完整的对象,key是ThreadLocal本身,value是ThreadLocal的泛型值。
  • 并发多线程场景下,每个线程Thread,在往ThreadLocal里设置值的时候,都是往自己的ThreadLocalMap里存,读也是以某个ThreadLocal作为引用,在自己的map里找对应的key,从而可以实现了线程隔离。

 

内存泄露问题:指程序中动态分配的堆内存由于某种原因没有被释放或者无法释放,造成系统内存的浪费,导致程序运行速度减慢或者系统奔溃等严重后果。内存泄露堆积将会导致内存溢出。

ThreadLocal的内存泄露问题一般考虑和Entry对象有关,ThreadLocal::Entry被弱引用所修饰。JVM会将弱引用修饰的对象在下次垃圾回收中清除掉。这样就可以实现ThreadLocal的生命周期和线程的生命周期解绑。但实际上并不是使用了弱引用就会发生内存泄露问题,考虑下面几个过程:

图片图片

当ThreadLocal Ref被回收了,由于在Entry使用的是强引用,在Current Thread还存在的情况下就存在着到达Entry的引用链,无法清除掉ThreadLocal的内容,同时Entry的value也同样会被保留;也就是说就算使用了强引用仍然会出现内存泄露问题。

图片图片

当ThreadLocal Ref被回收了,由于在Entry使用的是弱引用,因此在下次垃圾回收的时候就会将ThreadLocal对象清除,这个时候Entry中的KEY=null。但是由于ThreadLocalMap中任然存在Current Thread Ref这个强引用,因此Entry中value的值任然无法清除。还是存在内存泄露的问题。

AQS实现原理

 

AbstractQueuedSynchronizer(AQS)是Java中用于构建同步器的基础框架。它提供了一个灵活的、可重用的同步器实现,可以用来构建各种同步工具,如ReentrantLock、Semaphore、CountDownLatch等。AQS的核心思想是基于FIFO等待队列,通过状态(state)来管理线程的同步。

核心原理:

  • State(状态): AQS 的同步状态是一个整数,表示被同步的资源的状态。不同的同步器会使用不同的方式来表示状态的含义,例如,ReentrantLock 使用 state 表示持有锁的线程的数量,Semaphore 使用 state 表示可用的许可数量等。
  • FIFO 等待队列: AQS 使用一个FIFO的等待队列来管理获取同步资源失败的线程。每个节点(Node)表示一个等待线程,节点中保存了等待状态、前驱节点、后继节点等信息。当一个线程尝试获取锁但失败时,它会被包装成一个节点并加入到等待队列中。
  • 独占模式和共享模式: AQS 支持独占模式和共享模式。独占模式表示只有一个线程能够获取同步资源,如ReentrantLock 就是独占模式的同步器。共享模式表示多个线程可以同时获取同步资源,如Semaphore 就是共享模式的同步器。AQS 使用 acquire 和 release 方法来分别表示获取和释放同步资源。
  • acquire 方法: 当线程尝试获取同步资源时,它会调用 AQS 的 acquire 方法。acquire 方法会根据同步状态的不同情况进行处理,如果同步状态允许当前线程获取资源,则直接返回;否则,当前线程会被包装成节点并加入到等待队列中,然后进入自旋等待状态,直到获取到资源。
  • release 方法: 当线程释放同步资源时,它会调用 AQS 的 release 方法。release 方法会根据同步状态的不同情况进行处理,然后唤醒等待队列中的下一个线程,使其有机会获取资源。
  • 独占锁和共享锁的实现: AQS 提供了独占锁的实现方法 tryAcquire 和 tryRelease,以及共享锁的实现方法 tryAcquireShared 和 tryReleaseShared。

ReentrantLock 解析:

图片图片

图片图片

上下文切换

 

CPU上下文:CPU 寄存器,是CPU内置的容量小、但速度极快的内存。而程序计数器,则是用来存储 CPU 正在执行的指令位置、或者即将执行的下一条指令位置。它们都是 CPU 在运行任何任务前,必须的依赖环境,因此叫做

CPU上下文切换:把前一个任务的CPU上下文(也就是CPU寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。

图片图片

  • 分时调度:让所有的线程轮流获得CPU的使用权,并且平均分配每个线程占用的 CPU 的时间片。
  • 抢占式调度:优先让可运行池中优先级高的线程占用CPU,如果可运行池中的线程优先级相同,那么就随机选择一个线程,使其占用CPU。处于运行状态的线程会一直运行,直至它不得不放弃 CPU。


Tags:多线程技术   点击:()  评论:()
声明:本站部分内容及图片来自互联网,转载是出于传递更多信息之目的,内容观点仅代表作者本人,不构成投资建议。投资者据此操作,风险自担。如有任何标注错误或版权侵犯请与我们联系,我们将及时更正、删除。
▌相关推荐
深入理解与应用多线程技术
如果synchronized​作用于代码块,反编译可以看到两个指令:monitorenter、monitorexit,JVM​使用monitorenter和monitorexit​两个指令实现同步;如果作用synchronized​作用于方...【详细内容】
2024-01-09  Search: 多线程技术  点击:(81)  评论:(0)  加入收藏
Python并发编程:多线程技术详解
在这个教程中,我们将带你了解Python多线程!作为一名Python开发者,你可能一直在想如何提高程序性能,让任务同时高效地执行。别担心,本教程将深入浅出地介绍多线程的艺术和威力。一...【详细内容】
2023-08-31  Search: 多线程技术  点击:(136)  评论:(0)  加入收藏
▌简易百科推荐
即将过时的 5 种软件开发技能!
作者 | Eran Yahav编译 | 言征出品 | 51CTO技术栈(微信号:blog51cto) 时至今日,AI编码工具已经进化到足够强大了吗?这未必好回答,但从2023 年 Stack Overflow 上的调查数据来看,44%...【详细内容】
2024-04-03    51CTO  Tags:软件开发   点击:(5)  评论:(0)  加入收藏
跳转链接代码怎么写?
在网页开发中,跳转链接是一项常见的功能。然而,对于非技术人员来说,编写跳转链接代码可能会显得有些困难。不用担心!我们可以借助外链平台来简化操作,即使没有编程经验,也能轻松实...【详细内容】
2024-03-27  蓝色天纪    Tags:跳转链接   点击:(12)  评论:(0)  加入收藏
中台亡了,问题到底出在哪里?
曾几何时,中台一度被当做“变革灵药”,嫁接在“前台作战单元”和“后台资源部门”之间,实现企业各业务线的“打通”和全域业务能力集成,提高开发和服务效率。但在中台如火如荼之...【详细内容】
2024-03-27  dbaplus社群    Tags:中台   点击:(8)  评论:(0)  加入收藏
员工写了个比删库更可怕的Bug!
想必大家都听说过删库跑路吧,我之前一直把它当一个段子来看。可万万没想到,就在昨天,我们公司的某位员工,竟然写了一个比删库更可怕的 Bug!给大家分享一下(不是公开处刑),希望朋友们...【详细内容】
2024-03-26  dbaplus社群    Tags:Bug   点击:(5)  评论:(0)  加入收藏
我们一起聊聊什么是正向代理和反向代理
从字面意思上看,代理就是代替处理的意思,一个对象有能力代替另一个对象处理某一件事。代理,这个词在我们的日常生活中也不陌生,比如在购物、旅游等场景中,我们经常会委托别人代替...【详细内容】
2024-03-26  萤火架构  微信公众号  Tags:正向代理   点击:(10)  评论:(0)  加入收藏
看一遍就理解:IO模型详解
前言大家好,我是程序员田螺。今天我们一起来学习IO模型。在本文开始前呢,先问问大家几个问题哈~什么是IO呢?什么是阻塞非阻塞IO?什么是同步异步IO?什么是IO多路复用?select/epoll...【详细内容】
2024-03-26  捡田螺的小男孩  微信公众号  Tags:IO模型   点击:(8)  评论:(0)  加入收藏
为什么都说 HashMap 是线程不安全的?
做Java开发的人,应该都用过 HashMap 这种集合。今天就和大家来聊聊,为什么 HashMap 是线程不安全的。1.HashMap 数据结构简单来说,HashMap 基于哈希表实现。它使用键的哈希码来...【详细内容】
2024-03-22  Java技术指北  微信公众号  Tags:HashMap   点击:(11)  评论:(0)  加入收藏
如何从头开始编写LoRA代码,这有一份教程
选自 lightning.ai作者:Sebastian Raschka机器之心编译编辑:陈萍作者表示:在各种有效的 LLM 微调方法中,LoRA 仍然是他的首选。LoRA(Low-Rank Adaptation)作为一种用于微调 LLM(大...【详细内容】
2024-03-21  机器之心Pro    Tags:LoRA   点击:(12)  评论:(0)  加入收藏
这样搭建日志中心,传统的ELK就扔了吧!
最近客户有个新需求,就是想查看网站的访问情况。由于网站没有做google的统计和百度的统计,所以访问情况,只能通过日志查看,通过脚本的形式给客户导出也不太实际,给客户写个简单的...【详细内容】
2024-03-20  dbaplus社群    Tags:日志   点击:(4)  评论:(0)  加入收藏
Kubernetes 究竟有没有 LTS?
从一个有趣的问题引出很多人都在关注的 Kubernetes LTS 的问题。有趣的问题2019 年,一个名为 apiserver LoopbackClient Server cert expired after 1 year[1] 的 issue 中提...【详细内容】
2024-03-15  云原生散修  微信公众号  Tags:Kubernetes   点击:(5)  评论:(0)  加入收藏
站内最新
站内热门
站内头条