并发编程学习笔记

并发编程

什么是进程

操作系统进行资源分配的基本单位

什么是线程

一个程序不同的执行路径,调度的执行的基本单位,多个线程共享同一个进程里的资源

  • 单线程:程序中没有同时执行的运行路径(主线程-main)
  • 多线程:程序中同时不同的分支在同时运行

线程切换

在这里插入图片描述

  • CPU:ALU(计算单元)+ Registers(寄存器)+ PC(程序计数器)
  • ALU:对寄存器中的数据运行指定进行计算
  • Registers:存放线程的数据
  • PC:线程执行到的指令

某线程执行时将其指令与数据放入CPU进行运算再放入缓存区,当OS切换到其他线程,该线程重复先前步骤如此来回切换,由哪个线程占用CPU执行由OS决定(缓存区的线程也会被OS切换执行)

从底层的角度出发,线程的切换也是需要消耗资源的(由OS进行线程的切换)

什么是纤程/协程

1

什么是程序

可执行文件,如exe文件

单核CPU设定多线程是否有意义

有意义,有的线程是CPU密集型,大量时间在做计算;有的线程是IO密集型,大量的时间在等,等时间到了,做一些简单的拷贝等操作;在等待的时候即可切换到其他线程

工作线程数是不是设置的越大越好

不是,线程的上下文切换是由用户态装换为内核态,会消耗CPU资源

工作线程数(线程池中线程数量)设置多少合适

计算公式

在这里插入图片描述

如:等待与计算时间同为50%,当有1核CPU,期望利用率为100%的时候,设置2个线程最佳(1*1*2);同理有两核CPU时4个线程最佳

  • 在没有其他线程的条件下可根据CPU的核数进行设置,但在同一台机器上不一定只有正在跑的线程;
  • 从安全角度,不能让CPU百分之百,充分利用CPU还得给CPU留点余量 20%

一般来说需要使用工具(Profiler)进行测算(针对单机)

  • 本地开发:JProfiler
  • 远程服务:Arthas
创建线程的五种方法
  • 继承Thread,重写run方法,最后继承的对象调用start()开启线程(Java类单继承)

  • 实现Runable接口,重写run方法,构建Thread对象传入该对象调用start()开启线程(更灵活,实现Runable还可从其他类继承)

  • lambda表达式new Thread(() -> {XXX}).start();

  • 线程池

    ExecutorService service = Executors.new CachedThreadPool();
    service.execute(() -> {
      	System.out.println("Hello ThreadPool");
    });
    service.shutdown();
    
  • 实现Callable<T>,重写call方法,通过指定范型定义call方法的返回值

    • 使用线程池执行

      //这里假设MyCall实现了Callable<String>,使用如上线程池
      Future<String> f = service.submit(new MyCall());
      String s = f.get(); //获取call()的返回值,该方法为线程阻塞的(待s获取到返回值后才能往下继续执行)
      service.shotdown();
      
    • 创建线程执行

      //假设MyCall实现了Callable<String>
      FutureTask<String> task = new FutureTask<>(new MyCall());
      new Thread(task).start();
      task.get();
      

如上方式本质上都是new Thread().start()

JAVA的6中线程状态
  1. NEW : 线程刚刚创建,还没有启动
  2. RUNNABLE : 可运行状态,由线程调度器可以安排执行
    • 包括READY和RUNNING两种细分状态
  3. WAITING: 等待被唤醒
  4. TIMED WAITING: 隔一段时间后自动唤醒
  5. BLOCKED: 被阻塞,正在等待锁
  6. TERMINATED: 线程结束

如下图:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wEOY5Fgp-1662305409550)(/Users/armin/Desktop/file/多线程与高并发/多线程与高并发/./img/线程状态图.png)]

只有synchronized(会经过OS的调度)时,线程状态才会进入BLOCKED,其余时候线程阻塞一般都是WAITING

线程打断interrupt
  1. interrupt():打断某个线程(设置标志位)
  2. isInterrupted():查询某线程是否被打断过(查询标志位)
  3. static interrupted():查询当前线程是否被打断过(查询标志位),并重置打断标志

sleep() wait() join()

  • 当前线程设置标志位位true时会抛InterruptedException异常并且重置标志位为false
  • 锁竞争(synchronized,ReentrantLock)的过程中不会被interrupte()干扰,也不会抛异常
  • 如果想要在锁竞争时打断使用ReentrantLock对象的lockInterruptibly()方法
线程的结束
  1. 自然结束(能自然结束就尽量自然结束)
  2. Thread对象的stop():不建议,容易产生数据不一致的问题(直接停止线程,不处理善后工作)
  3. Thread对象的suspend()(暂停)与resume()(继续):不建议,容易产生死锁问题(如果当前线程持有一把锁,并且暂停了,他是不会释放锁的,什么时候继续未知,所以导致可能产生死锁)
  4. private static volatile flag:相对优雅的结束方式,缺陷在于不能精确的控制结束的时机,不能依赖中间状态
    • 不适合某些场景(如:还没有同步的时候,线程做了阻塞操作,没有办法循环回去)
    • 打断的时间也不是特别精确(如:一个阻塞容器,容量为5的时候结束生产者,但是由于volatile同步线程标志位的时间控制不是很精确,有可能生产者还继续生产一段时间)
  5. Interrupt() and isInterrupted()进行判断,比较优雅,但也不能控制结束的时机(需要用到锁才能进行精准控制)

并发编程三大特性

  • 可见性(visibility)
  • 有序性(ordering)
  • 原子性(atomicity)
可见性

多个线程中,一个线程修改的同一变量,其他线程,都能读到修改后的值(线程本地备份和主存数据的同步)

如何保证可见性

在这里插入图片描述

每个线程运行时,都会把变量拷贝到线程本地,直接修改running是不会修改到t1线程中拷贝的running值

  1. 使用volatile修饰:当使用volatile修饰running后,每次读的时候都是从主存中获取
    • volatile修饰引用类型,只能保证引用本身的可见性,不能保证内部字段的可见性
  2. 某些语句的执行会触发:synchronized

缓存行(一个Cache Line = 64Byte)

根据缓存行的概念,多线程修改同一缓存行的数据,效率会变低(同一缓存行中不同对象由于对象无法达到64字节,所以每个线程得到的都是不同的相邻对象,当这相邻的对象被不同的线程赋值,由于不同线程拿到的数据都是一个缓存行,所以相邻的对象也都拿到了,但不同线程修改这同一缓存行由于底层的协议机制【缓存一致性协议:MESI Cache其中一种(多个CPU之间,其中一个数据修改后,会通知另外一个CPU数据已修改)】会相互影响)

缓存行为什么是64字节:

  1. 缓存行越大,局部性空间效率越高,单独去时间慢
  2. 缓存行越小,局部空间效率越低,但读取时间快
  3. 去一个折中值,目前多用:64byte

@Comtended

  • 定义在全局变量,该变量自动补齐一个缓存行
  • 需要加jvm参数:-XX:-RestrictContended(去除限制)
  • 仅限于JDK1.8
有序性

概念:单线程保证最终一致性
程序真实按顺序执行的吗

未必(乱序执行在多线程的情况下可能产生难以察觉的错误)

为何乱序

简单说,为了提高效率;乱序前提,前后两条语句没有依赖关系(不影响单线程的最终一致性)

举个例子:我要煮10个饺子,但饺子还没包,难道我需要等水开了再去包饺子再下饺子?未必,我可以再烧水的时候把饺子包好待水开后直接下饺子

通过一个小程序认识可见性和有序性

  • ready没有volatile修饰;可能会存在主线程的赋值t线程中不可见
  • number 与 ready 变量没有依赖关系:导致number可能输出0
public class T02_NoVisibility {
    private static boolean ready = false;
    private static int number;

    private static class ReaderThread extends Thread {
        @Override
        public void run() {
            while (!ready) {
                Thread.yield(); //让当前线程从运行状态转为就绪状态
            }
            System.out.println(number);
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t = new ReaderThread();
        t.start();
        number = 42;
        ready = true; //由于缓存一致性协议MESI的主动性也可能会更新线程中的该变量
        t.join(); //join方法将挂起调用线程的执行,直到被调用的对象完成它的执行
    }
}

对象的半初始化状态

在这里插入图片描述

  • 0:T对象分配堆内存空间,成员变量初始化m=0(对象的半初始化状态)
  • 4:调用构造方法m=8(初始化完成)
  • 7:对象建立关联 t = new T();

如下程序有可能输出中间状态num=0

原因:指令7与4互换了顺序

import java.io.IOException;

public class T03_ThisEscape {
    private int num = 8;
    
    public T03_ThisEscape() {
        new Thread(() -> System.out.println(this.num)).start();
    }

    public static void main(String[] args) throws IOException {
        new T03_ThisEscape();
        System.in.read(); //目的阻塞当前主线程,保证T03_ThisEscape执行完
    }
}

解决方法:不要再构造方法中直接启动线程

import java.io.IOException;

public class T03_ThisEscape {
    private int num = 8;
    Thread t;

    public T03_ThisEscape() {
        t = new Thread(() -> System.out.println(this.num));
    }
    
    public void start() {
        t.start();
    }

    public static void main(String[] args) throws IOException {
        new T03_ThisEscape();
        System.in.read(); //目的阻塞当前主线程,保证T03_ThisEscape执行完
    }
}

JVM内存屏障(保证代码执行的有序性)

  • LoadLoad:读与读顺序不可换
  • StoreStore:写与写顺序不可换
  • LoadStore:读与写顺序不可换
  • StoreLoad:写与读顺序不可换

volatile实现细节(保证线程的可见性,禁止指令的重排序)

  • JVM层面

    在这里插入图片描述

    • 当写一个 volatile 变量时,JVM 会把该线程对应的本地内存中的共享变量刷新到主内存

    • 当读一个 volatile 变量时,JVM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量

      理解了这个语义就能很清楚的知道,volatile其实是指对线程本地内存的操作,volatile读之后,所有的读操作应该是从主内存中读取,不加LL屏障如果与后面的读操作发生了指令重排,后面的读操作就会读取到线程本地内存的数据

  • hotspot实现

    • bytecodeinterpreter.cpp

      int field_offset = cache->f2_as_index();
      if (cache->is_volatile()) {
          if (support_IRIW_for_not_multiple_copy_atomic_cpu) {
            	OrderAccess::fence();
          }
      }
      
    • orderaccess_linux_x86.inline.hpp

      inline void OrderAccess::fence() {
          if (os::is_MP()) {
              // always use locked addl since mfence is sometimes expensive
          #ifdef AMD64
              __asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
          #else
              __asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
          #endif
          }
      }
      

    LOCK 用于在多处理器中执行指令时对共享内存的独占使用
    它的作用是能够将当前处理器对应缓存的内容刷新到内存,并使其他处理器对应的缓存失效

    另外还提供了有序的指令无法越过这个内存屏障的作用

原子性

原子性操作:当前线程操作不被其他线程所打断(如:当前线程给全局变量a赋值,等当前线程赋值完成后其他线程才能对该变量进行操作)

涉及的一些概念:

  • race condition(竞争条件):指多个线程访问共享数据产生的竞争,导致数据不一致(unconsistency),并发访问下产生的不期望出现的结果
    • 如何保证数据一致:线程同步(并发编程序列化)
  • monitor(管程):锁
  • critical section:临界区(如synchronized代码块中的代码称为临界区;当持有锁的时候,执行的代码必须按序列化执行)
    • 如果临界区执行时间长,语句多,叫做:锁的粒度比较粗,反之锁的粒度比较细

上锁的本质

将并发编程序列化(原本的并发执行变为线性执行,多个线程上锁时必须保证为同一把锁)

保障操作的原子性

  1. 悲观的认为这个操作会被别的线程打断(悲观锁【重量级】)synchronized(保障:可见性、原子性)

  2. 乐观的认为这个操作不会被别的线程打断(乐观锁/自旋锁/无锁【轻量级】)CAS(Compare And Set/Swap/Exchange)操作

    • CAS

      在这里插入图片描述

      • 自旋:在E和当前的新值N比较不等时,从新读取N进行计算再比较的过程

      • ABA问题

        • 如果E为非引用类型不需要解决
        • 如果E为引用类型:加版本解决,每个线程拿到该引用对其版本进行修改
          • 版本参数定义:时间戳/数字/boolean
      • CAS机制要起作用,CAS本省就需要保证原子性(在比较赋值的时候)

      • AtomicXXX类CAS底层实现:本质上还是加锁了(保证了原子性)

        lock cmpxchg //汇编指令(跟踪本地C++方法发现)
        

两种锁的效率

不同场景:

  • 重量级(synchronized):临界区执行时间比较长,等的人多
  • 自旋锁:临界区执行时间比较短,等的人少
  1. 悲观锁不会过多的消耗CPU,但会线性执行
  2. 乐观锁某些情况会一直自旋进而消耗CPU资源

实战采用synchronized,其内部即有自旋锁又有偏向锁以及重量级锁,内部进行调优升级已经很不错了

synchronized如何保障可见性

在解锁后会将内存的所有状态与缓存的状态进行刷新对比,然后下一个线程才能继续

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值