并发编程的魅力

并行与并发

单核CPU下,线程实际是串行执行的。操作系统中有哦一个组件叫任务调度器,将CPU的时间片分给不同的线程使用,只是由于CPU在线程间(时间片很短)的切换非常快,人类感觉是同时运行的。

总结为一句话就是:围观串行,宏观并行,一般会将这种线程轮流使用CPU得到做法成为并发,concurrent。

在这里插入图片描述

多核CPU下,每个核(core)都可以调度运行线程,这时候线程可以是并行的。

在这里插入图片描述

大家排队在一个咖啡机上接咖啡,交替执行,是并发;两台咖啡机上面接咖啡,是并行。

从严格意义上来说,并行的多任务是真的同时执行,而对于并发来说,这个过程是交替的,一会执行任务A,一会执行任务B,系统会不停地在两者之间切换。

并发说的是在一个时间段内**,多件事情在这个时间段内交替执行

并行说的是多件事情在同一个时刻同事发生。

多线程

java是最先支持多线程的开发语言之一,java从一开始就支持了多线程能力。由于现在的CPU已经是多核处理器的,是可以同时执行多个线程的。

多线程优点

多线程技术使程序的响应速度更快,可以在进行其它工作的同时一直处于活动状态,程序性能得到提升。

性能提升的本质,就是榨取硬件的剩余价值(硬件利用率)。

多线程问题

安全性(访问共享变量),性能(切换开销等)

java内存模型(JMM)

问题

硬件的发展中,一直存在一个矛盾,CPU、内存、I/O设备的速度差异

速度排序:CPU>内存>I/O设备

为了平衡这三者的速度差异,做了如下优化:

​ CPU增加了缓存,以均衡内存与CPU的速度差异;

​ 操作系统以线程分时复用CPU,进而均衡I/O设备与CPU的速度差异;

​ 编译程序优化指令执行次序,是的缓存能够得到更加合理地利用。

JMM

java内存模型(Java Memory Model,JMM)规范了java虚拟机与计算机内存是如何协同工作的。java虚拟机是一个完整的计算机的一个模型,因此这个模型自然也包含一个内存模型——又称为java内存模型。

java内存模型,用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让java程序在各种平台下都能达到一直的并发效果,JMM规范了java虚拟机与计算机内存是如何协同工作,规定过了一个线程如何以及合适可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。

他的工作:java多线程在工作时,现将主内存中的数据 读到线程工作内存(缓存), 然后在工作内存中,对数据进行操作,操作完成后,再将数据写回到主内存.

JVM主内存与工作内存

Java 内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量(线程共享的变量)存储到内存和从内存中取出变量这样底层细节。

Java 内存模型中规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。

这里的工作内存是 JMM 的一个抽象概念,也叫本地内存,其存储了该线程以读 / 写共享变量的副本。

就像每个处理器内核拥有私有的高速缓存,JMM 中每个线程拥有私有的本地内存。

不同线程之间无法直接访问对方工作内存中的变量,线程间的通信一般有两种方式进行,一是通过消息传递,而是共享内存。java线程间的通信采用的事共享内存方式,线程、主内存和工作内存的关系如下图所示:
在这里插入图片描述

这里所讲的主内存、工作内存与 Java 内存区域中的 Java 堆、栈、方法区等并不是同一个层次的内存划分,这两者基本上是没有关系的,如果两者一定要勉强对应起来,那从变量、主内存、工作内存的定义来看,主内存主要对应于 Java 堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。

并发编程核心问题–可见性,原子性,有序性

可见性

一个线程对共享变量的修改,另一个线程能够立刻看到,我们称为可见性。

现在的多核处理器,每颗CPU都有自己的缓存,而缓存仅仅对它所在的处理器可见,CPU缓存与内存的数据不容易保证一致。

为了避免处理器停顿下来等待向内存写入数据而产生的延迟,处理器使用写缓冲区来临时保存向内存写入的数据。写缓冲区合并对同一内存地址的多次写,并以批处理的方式刷新,也就是说写缓冲区不会及时将数据刷新到主内存中。缓存不能及时刷新导致了可见性问题。

举例

假设线程 1 和线程 2 同时开始执行,那么一次都会将 a=0 读到各自的CPU 缓存里,线程 1 执行 a++之后 a=1,但是此时线程 2 是看不到线程 1 中 a 的值的,所以线程 2 里 a=0,执行 a++后 a=1。

线程 1 和线程 2 各自 CPU 缓存里的值都是 1,之后线程 1 和线程 2 都会将自己缓存中的 a=1 写入内存,导致内存中 a=1,而不是我们期望的 2。

在这里插入图片描述

原子性

原子的意思代表着——“不可分”;

一个或多个操作在 CPU 执行的过程中不被中断的特性,们称为原子性。

原子性是拒绝多线程交叉操作的,不论是多核还是单核,具有原子性的量,同一个时刻只能有一个线程来对它进行操作。

CPU能保证的原子操作是CPU指令级别的,而不是高级语言的操作符。线程切换导致了原子性问题。

在这里插入图片描述

java并发程序都是基于多线程的,自然也会设计到任务切换,任务切换的时机大多数是在时间片结束的时候。我们现在基本都使用高级语言编程,高级语言里的一条语句往往需要多条CPU指令完成。如count++,至少需要三条CPU指令。

​ 指令 1:首先,需要把变量 count 从内存加载到工作内存;

​ 指令 2:之后,在工作内存执行 +1 操作;

​ 指令 3:最后,将结果写入内存;

两个线程 A 和 B 同时执行 count++, 即便 count 使用 volatile 修辞,我们预期的结果值是 2,但实际可能是 1。

在这里插入图片描述

有序性

有序性指的是程序按照代码的先后顺序执行。

编译器为了优化性能,有时候会改变程序中语句的先后顺序。

在这里插入图片描述

例子

//线程1
init();
inited = true;

//线程2
while(inited){
    work();
}

init();与inited = true; 并没有数据的依赖,在单线程看来,如果把两句的代码调换好像也不会出现问题。

但此时处于一个多线程的环境,而处理器真的把这两句代码重新排序,那问题就出现了,若线程 1 先执行inited = true;此时,init() 并没有执行,线程2就已经开始调用work() 方法,此时很可能造成一些崩溃或其他问题的出现。

java内存模型中,允许编译器和处理器对指令进行重新排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。

总结

缓存导致的可见性问题线程切换带来的原子性问题编译优化带来的有序性问题。其实缓存、线程、编译优化的目的和我们写并发程序的目的是相同的,都是提高程序安全性和性能。但是技术在解决一个问题的同时,必然会带来另一个问题,所以在采用一项技术的同时,一定要看清楚它带来的问题是什么,以及如何规避。

解决问题

volatile 关键字

一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后:

  1. 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变 量的值,这新值对其他线程来说是立即可见的。
  2. 禁止进行指令重排序。
  3. volatile 不能保证对变量操作的原子性。
public class ThreadDemo implements  Runnable{

    /*
       volatile 修饰的变量,在一个线程中被修改后,对其它线程立即可见
                                            禁止cpu对指令重排序
     */
    private  volatile   boolean flag = false;//共享数据

    @Override
    public void run() {
        try {
            Thread.sleep(200);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        this.flag = true;//让一个线程修改共享变量值
        System.out.println(this.flag);
    }

    public boolean getFlag() {
        return flag;
    }

    public void setFlag(boolean flag) {
        this.flag = flag;
    }
}

如何保证原子性

“同一时刻只有一个线程执行”我们称之为互斥。如果我们能够保证对共享变量的修改是互斥的那么就都能保证原子性了。

锁是一种通用的技术方案,Java语言提供 synchronized 关键字,就是锁的 一种实现。

在这里插入图片描述

synchronized 是独占锁/排他锁(就是有你没我的意思),但是注意! synchronized 并不能改变 CPU 时间片切换的特点,只是当其他线程要访问这个 资源时,发现锁还未释放,所以只能在外面等待。

synchronized 一定能保证原子性,因为被 synchronized 修饰某段代码后,无论是单核 CPU 还是多核 CPU,只有一个线程能够执行该代码,所以一定能保证原子操作.

synchronized 也能够保证可见性和有序性。

JUC–原子变量

现在我们已经知道互斥锁可以保证原子性,也知道了如何使用synchronized来保证原子性。但synchronized 并不是java中唯一能保证原子性的方案。

如果你粗略的看一下 J.U.C(java.util.concurrent 包),那么你可以很显眼的发现它俩:

在这里插入图片描述

一个是 locks 包,一个是 atomic 包,它们可以解决原子性问题。

加锁是一种阻塞式方式实现

原子变量是非阻塞式方式实现

原子类

原子类原理(AtomicInteger 为例)

原子类的原子性是通过 volatile + CAS 实现原子操作的。

AtomicInteger 类中的 value 是有 volatile 关键字修饰的,这就保证了 value的内存可见性,这为后续的 CAS 实现提供了基础。

低并发情况下:使用 AtomicInteger。

public class ThreadDemo implements Runnable{

    // private  int num = 0;//共享变量
    // private volatile int num = 0;
     private AtomicInteger num = new AtomicInteger(0);
    @Override
    public void run() {
        try {
            Thread.sleep(200);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName()+":::"+getNum());
    }

    public int getNum() {
        //return num++;//num++ 不是原子性的
       // return num.incrementAndGet(); ++i;
        return num.getAndIncrement();//i++
    }
}
public class Test {
    public static void main(String[] args) {
        ThreadDemo td = new ThreadDemo();

        for (int i = 0; i <10 ; i++) {//循环创建10个线程
            Thread t = new Thread(td);
            t.start();
        }
    }
}

CAS

CAS(Compare-And-Swap) :比较并交换,该算法是硬件对于并发操作的支持。

CAS是乐观锁的一种实现方式,他采用的是自旋锁的思想,是一种轻量级的锁机制。

即每次判断我的预期值和内存中的值是不是相同,如果不相同则说明该内存值已经被其它线程更新过了,因此需要拿到该最新值作为预期值,重新判断。而该线程不断的循环判断是否该内存值已经被其他线程更新过了,这就是自旋的思想。

CAS包含了三个操作数:

​ ①内存值 V

​ ②预估值 A (比较时,从内存中再次读到的值)

​ ③更新值 B (更新后的值)

当且仅当预期值 A==V,将内存值 V=B,否则什么都不做。

这种做法的效率高于加锁,当判断不成功不能更新值时,不会阻塞,继续获得 cpu 执行权,继续判断执行.

CAS缺点

CAS 使用自旋锁的方式,由于该锁会不断循环判断,因此不会类似 synchronize 线程阻塞导致线程切换。但是不断的自旋,会导致 CPU 的消耗,在并发量大的时候容易导致 CPU 跑满。

ABA问题

ABA 问题,即某个线程将内存值由 A 改为了 B,再由 B 改为了 A。当另外一个线程使用预期值去判断时,预期值与内存值相同,误以为该变量没有被修改过而 导致的问题。

解决 ABA 问题的主要方式,通过使用类似添加版本号的方式,来避免 ABA 问题。

如原先的内存值为(A,1),线程将(A,1)修改为了(B,2),再由(B,2) 修改为(A,3)。此时另一个线程使用预期值(A,1)与内存值(A,3)进行比较,只需要比较版本号 1 和 3,即可发现该内存中的数据被更新过了。

JUC常用类

Java 5.0 在 java.utilconcurrent 包中提供了多种并发容器类来改进同步容器的性能.

ConcurrentHashMap

ConcurrentHashMap 同步容器类是 Java 5 增加的一个线程安全的哈希表。对与多线程的操作,介于 HashMap 与 Hashtable 之间。内部采用“锁分段”机制(jdk8 弃用了分段锁,使用 cas+synchronized)替代 Hashtable 的独占锁。进而提高性能。

放弃分段锁的原因

1.加入多个分段锁浪费内存空间。

2.生产环境中, map 在放入时竞争同一个锁的概率非常小,分段锁反而会造成更新等操作的长时间等待。

jdk8 放弃了分段锁而是用了 Node 锁,减低锁的粒度,提高性能,并使用 CAS 操作来确保 Node 的一些操作的原子性,取代了锁。

put 时首先通过 hash 找到对应链表过后,查看是否是第一个 Node,如果是, 直接用 cas 原则插入,无需加锁。

然后, 如果不是链表第一个 Node, 则直接用链表第一个 Node 加锁,这里加的锁是 synchronized。

在这里插入图片描述

public class HashMapDemo {

    /*
       HashMap是线程不安全的,不能并发操作的
       ConcurrentModificationException  并发修改异常   遍历集合,并删除集合中的数据

       Hashtable 是线程安全的 public synchronized V put(K key, V value)-->独占锁
            锁直接加到了put方法上,锁粒度比较大,效率比较低
            用在低并发情况下可以

       Map<String,Integer> map = Collections.synchronizedMap(new HashMap<>());
       ConcurrentHashMap
     */
    public static void main(String[] args) {

        ConcurrentHashMap<String,Integer> map = new ConcurrentHashMap<>();
        //模拟多个线程对其操作
        for (int i = 0; i < 20; i++) {
                 new Thread(
                     ()->{
                       map.put(Thread.currentThread().getName(), new Random().nextInt());
                         System.out.println(map);
                     }
                 ).start();
        }


        Vector v  = new Vector();
        v.add("");
        v.get(1);

        CopyOnWriteArrayList copy = new CopyOnWriteArrayList();
        copy.add("");
        copy.get(1);
    }
}

CopyOnWriteArrayList

ArraayList 是线程不安全的,在高并发情况下可能会出现问题, Vector 是线程安全的.

但是在很多应用场景中,读操作可能会远远大于写操作。由于读操作根本不会修改原有的数据,因此如果每次读取都进行加锁操作,其实是一种资源浪费。我们应该允许多个线程同时访问 List 的内部数据,毕竟读操作是线程安全的。

JDK 中提供了 CopyOnWriteArrayList 类,将读取的性能发挥到极致,取是完全不用加锁的,并且更厉害的是:写入也不会阻塞读取操作,只有写入和写入之间需要进行同步等待,读操作的性能得到大幅度提升。

CopyOnWriteArrayList 类的所有可变操作(add,set 等等)都是通过创建底层数组的新副本来实现的。当 List 需要被修改的时候,并不直接修改原有数组对象,而是对原有数据进行一次拷贝,将修改的内容写入副本中。写完之后,再将修改完的副本替换成原来的数据,这样就可以保证写操作不会影响读操作了。

CopyOnWriteArraySet

CopyOnWriteArraySet 的实现基于 CopyOnWriteArrayList,不能存储重复数据.

public class ArrayListDemo {

    /*
        ArrayList alist = new ArrayList<>(); 并发情况下不能使用ArrayList,不能同时有两个线程对其操作
        Collections.synchronizedList()<>();
        Vector alist = new Vector(); 是线程安全的 但是对读操作也加了锁, 读和写用的是同一把锁
        CopyOnWriteArrayList
          对读 和 写进行分离
          读操作完全不用加锁,读不影响数据
          写操作加锁,写操作时不影响读操作  只有两个线程同时添加时会互斥

          添加时先将原数组复制出一个副本
          然后将数据添加到副本中   不影响读操作
          最后再用副本 替换原数组

          CopyOnWriteArraySet一个不允许重复数据的单例集合 底层实现使用CopyOnWriteArrayList
          添加数据时会判断是否有重复元素
     */
    public static void main(String[] args) {

        CopyOnWriteArrayList alist = new CopyOnWriteArrayList();
        //模拟多个线程对其操作
        for (int i = 0; i < 20; i++) {
                 new Thread(
                     ()->{
                         alist.add(1);
                         System.out.println(alist);
                     }
                 ).start();
                 
        }
    }
}

辅助类 CountDownLatch

CountDownLatch 这个类使一个线程等待其他线程各自执行完毕后再执行。是通过一个计数器来实现的,计数器的初始值是线程的数量。每当一个线程执行完 毕后,计数器的值就-1,当计数器的值为 0 时,表示所有线程都执行完毕,然后在闭锁上等待的线程就可以恢复工作了。

public class CountDownLatchDemo {

    /*
      CountDownLatch 辅助类  递减计数器
         使一个线程 等待其他线程执行结束后再执行
         相当于一个线程计数器,是一个递减的计数器
         先指定一个数量,当有一个线程执行结束后就减一 直到为0 关闭计数器
         这样线程就可以执行了
     */

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

        CountDownLatch downLatch = new CountDownLatch(6);//计数
        for (int i = 0; i <6 ; i++) {
            new Thread(
                ()->{
                    System.out.println(Thread.currentThread().getName());
                    downLatch.countDown();//计数器减一操作
                }
            ).start();
        }
        downLatch.await();//关闭计数

        System.out.println("main线程执行");
    }
}

辅助类 CyclicBarrier

CyclicBarrier 是一个同步辅助类,让一组线程到达一个屏障时被阻塞,直到最后一个线程到达屏障时,屏障才会开门.

public class CyclicBarrierDemo {

    /*
       CyclicBarrier 让一组线程到达一个屏障时被阻塞,直到最 后一个线程到达屏障时,屏障才会开门
         是一个加法计数器,当线程数量到达指定数量时,开门放行
     */

    public static void main(String[] args) {
        CyclicBarrier c = new CyclicBarrier(5, ()->{
            System.out.println("大家都到齐了 该我执行了");
        });

        for (int i = 0; i < 5; i++) {
            new Thread(
                    ()->{
                        System.out.println(Thread.currentThread().getName());
                        try {
                            c.await();//加一计数器
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        } catch (BrokenBarrierException e) {
                            e.printStackTrace();
                        }
                    }
            ).start();
        }
    }
}

java中的锁分类

很多锁的名词,这些分类并不是全是指锁,有的指锁的特性,有的指锁的设计, 有的指锁的状态,下面总结的内容是对每个锁的名词进行一定的解释。

乐观锁/悲观锁

乐观锁与悲观锁不是指具体的什么类型的锁,而是指看待并发同步的角度。

乐观锁则认为对于同一个数据的并发操作,是不会发生修改的。在更新数据的时候,会采用尝试更新,不断重新的方式更新数据。乐观的认为,不加锁的并发操作是没有事情的。

悲观锁认为对于同一个数据的并发操作,一定是会发生修改的,哪怕没有修改, 也会认为修改。因此对于同一个数据的并发操作,悲观锁采取加锁的形式。悲观的认为,不加锁的并发操作一定会出问题。

从上面的描述我们可以看出,悲观锁适合写操作非常多的场景,乐观锁适合读操作非常多的场景,不加锁会带来大量的性能提升。

悲观锁在 Java 中的使用,就是利用各种锁。

乐观锁在 Java 中的使用,是无锁编程,常常采用的是 CAS 算法,典型的例子就是原子类,通过 CAS 自旋实现原子操作的更新。

可重入锁

可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。对于 Java ReentrantLock 而言, 他的名字就可以看出是一个可重入锁,其名字是 Reentrant Lock 重新进入锁。

对于 Synchronized 而言,也是一个可重入锁。可重入锁的一个好处是可一定程度避免死锁。

public class Demo{
    synchronized void setA() trrows Exception{
        System.out.println("方法A");
        setB();
    }
    synchronized void setB() trrows Exception{
        System.out.println("方法B");
    }
}

上面的代码就是一个可重入锁的一个特点,如果不是可重入锁的话,setB 不会被当前线程执行,造成死锁。

读写锁(ReadWriteLock)

读写锁特点:

  1. 多个读者可以同时进行读
  2. 写者鼻血互斥(只允许一个写者写,也不能读者写者同时进行)
  3. 写者优先于读者(一旦有写者,则后续读者必须等待,唤醒时优先考虑写者)
private int data;//共享数据
private ReadWriteLock rwl = new ReentrantReadWriteLock();
//写数据
public void set(int data){
    rwl.writeLock().lock();//取到写锁
    try{
        System.out.println(Thread.currentThread().getName() + "准备写入数据");
        this.data  = data;
        System.out.println(Thread.currentThread().getName() + "写入" + this.data);
    }finally{
        rwl.writeLock().unlock;//释放写锁
    }
}

//读数据
public void get(){
    rwl.readLock().lock();//取到读锁
    try{
        System.out.println(Thread.currentThread().getName() + "准备读取数据");
        System.out.println(Thread.currentThread() + "读取" + this.data);
    }finally{
        rwl.readLock().unlock();//释放读锁
    }
}

分段锁

分段锁并非一种实际的锁,而是一种思想,用于将数据分段,并在每个分段上都会 单独加锁,把锁进一步细粒度化,以提高并发效率。

自旋锁(SpinLock)

spin在英文中用于描述纺纱的纱轮疯狂自转的样子,一看这名字就很消耗CPU。

自旋锁其实并不属于锁的状态,从Mark Word 的说明可以看到,并没有一个锁状态叫自旋锁。所谓自旋其实指的就是自己重试,当线程抢锁失败后,重试几次,要是抢到锁了就继续,要是抢不到就阻塞线程。说白了还是为了尽量不要阻塞线程。

由此可见,自旋锁是比较消耗CPU的,因为要不断的循环重试,不会释放CPU资源。另外,加锁时间普遍较短的场景非常适合自旋锁,可以极大提高锁的效率。

共享锁/独占锁

共享锁是指该锁可被多个线程所持有,并发访问共享资源。

独占锁也叫互斥锁,是指该锁一次只能被一个线程所持有。

对于Java ReentrantLock,Synchronized 而言,都是独享锁。但是对于Lock的另一个实现类 ReadWriteLock,其读锁是共享锁,其写锁是独享锁。

读锁的共享锁课保证并发读是非常高效的,读写,写读,写写的过程是互斥的。

独享锁与共享锁也是通过AQS来实现的,通过实习爱你不同的方法,来实现独享或者共享。

AQS(AbstractQueuedSynchronizer)

类如其名,抽象的队列式的同步器,这个类在java.util.concurrent.locks包,AQS定义了一套多线程访问共享资源的同步框架,许多同步类实现都依赖于它,如常用的ReentrantLock…

核心思想

如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作进程,并将共享资源设置为锁定状态;如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁(CLH同步队列是一个FIFO双向队列,AQS依赖它来完成同步状态的管理)来实现的,即将暂时获取不到锁的线程加入到队列中。

AQS就是基于CLH队列,用volatile修饰共享变量state,线程通过CAS去改变状态符,成功则获取锁成功,失败则进入等待队列,等待被唤醒。

在这里插入图片描述

公平锁/非公平锁

公平锁(Fair Lock) 是指在分配锁前,检查是否有线程在排队等待获取该锁,优先将锁分配给排队时间最长的线程。

**非公平锁(Nonfair Lock)**是指在分配锁时不考虑线程排队等待的情况,直接尝试获取锁,在获取不到时再排到队尾等待。

因为公平锁需要在多核的情况下维护一个线程等待队列,基于该队列进行锁的分配,因此效率比非公平锁低很多。

对于synchronized而言,是一种非公平锁。

ReentrantLock 默认是非公平锁,但是底层可以通过AQS的来实现线程调度,所以可以使其变成公平锁。

//默认
public ReentrantLock(){
    sync = new NonfairSync();
}
//传入 true or false
public ReentrantLock(boolean fair){
    sync = fair ? new FairSync() : new NonfairSync();
}

偏向锁/轻量级锁/重量级锁

锁的状态

​ 无锁状态

​ 偏向锁状态

​ 轻量级锁状态

​ 重量级锁状态

锁的状态是通过对象的监视器在对象头中的字段来表明的。

四种状态会随着竞争的情况逐渐升级,而且是不可逆的过程,即不可降级。

这四种状态都不是java语言中的锁,而是jvm为了提高锁的获取与释放效率而做的优化(使用 synchronized 时)。

偏向锁

偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。

降低获取锁的代价。

轻量级

轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。

重量级锁

重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁,重量级锁会让其他申请的线程进入阻塞,性能降低。

对象头

在Hotspot虚拟机中,对象在内存中的布局分为三块区域:对象头、实例数据和对其填充;Java对象头是实现synchronized的锁对象的基础,一般而言,synchronized使用的锁对象是存储在java对象头里。它是轻量级锁和偏向锁的关键

在这里插入图片描述

Mawrk Word:

​ Mark Word 用于存储对象自身的运行时数据,如哈希码(HashCode)、 GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。Java对象头一般占有两个机器码(在 32 位虚拟机中,1 个机器码等于 4 字节,也就是 32bit),下面就是对象头的一些信息:

在这里插入图片描述

Synchronized

Java 提供的一种原子性性内置锁,Java 每个对象都可以把它当做是监视器锁,线程代码执行在进入 synchronized 代码块时候会自动获取内部锁,这个时 候其他线程访问时候会被阻塞,直到进入 synchronized 中的代码执行完毕或者抛出异常或者调用了 wait 方法,都会释放锁资源。在进入 synchronized 会从主内存把变量读取到自己工作内存,在退出的时候会把工作内存的值写入到主内存,保证了原子性。

synchronized 基于进入和退出监视器对象来实现方法同步和代码块同步。

同步方法使用 ACC_SYNCHRONIZED 标记是否为同步方法.当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,该标记表明线程进入该方法时,需要 monitorenter,退出该方法时需 要 monitorexit。

使用 javap -verbose SynchronizedDemo 反编译后得到:

在这里插入图片描述

代码块的同步是利用 monitorenter 和 monitorexit 这两个字节码指令。 在虚拟机执行到 monitorenter 指令时,首先要尝试获取对象的锁。

当前线程拥有了这个对象的锁,把锁的计数器+1;当执行 monitorexit 指令时将模计数器-1;当计数器为 0 时,锁就被释放了。

在这里插入图片描述

Java 中 synchronized 通过在对象头设置标记,达到了获取锁和释放锁的目的。

ReentrantLock

ReentrantLock 主要利用 CAS+AQS 队列来实现。它支持公平锁和非公平锁。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FifpQ0go-1644993831241)(E:\Users\asus\AppData\Roaming\Typora\typora-user-images\1644925944342.png)]

lock();

final void lock(){
    if(compareAndSetState(0,1)){
        setExclusiveOwnerThread(Thread.currentThread());
    }else{
        acquire(1);
    }
}

假设当前有三个线程去竞争锁,假设线程 A 的 CAS 操作成功了,获得了锁,将锁状态 state 改为 1,那么线程 B 和 C 则设置状态失败。

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

由于线程 A 已经占用了锁,所以 B 和 C 失败,并且入等待队列。如果线程 A 拿着锁死死不放,那么 B 和 C 就会被挂起。

B 和 C 相继入队尝试获取锁。

若当前线程的节点的前驱节点是 head,就有资格尝试获取锁。

在这里插入图片描述

unlock()

尝试释放锁,若释放成功,那么查看头结点的状态,如果是则唤醒头结点的下个节点关联的线程。

public void unlock(){
    sync.release(1);
}
publicfinal boolean release(int arg){
    if(tryRelease(arg)){
        Node h = head;
        if(h != null && h.waitStatus !=0){
            unparkSuccessor(h);
        }
        return true;
    }
    return false;
}

线程池

概述

以前我们需要使用线程的时候就去创建一个线程,这样实现起来非常简便,但是就会有一个问题:

如果并发的线程数量很多,并且每个线程都是执行一个时间很短的任务就结束了,这样频繁创建线程就会大大降低系统的效率,因为频繁创建线程和销毁线程需要时间。

那么有没有一种办法使得线程可以复用,就是执行完一个任务,并不被销毁,而是可以继续执行其他的任务?

在 Java 中可以通过线程池来达到这样的效果。线程池里的每一个线程代码结束后,并不会死亡,而是再次回到线程池中成为空闲状态,等待下一个对象来 使用。在 JDK5 之前,我们必须手动实现自己的线程池,从 JDK5 开始,Java 内置支持线程池.

在 JDK5 版本中增加了内置线程池实现 ThreadPoolExecutor,同时提供了 Executors 来创建不同类型的线程池。Executors 中提供了以下常见的线程池创建方法:

newSingleThreadExecutor:一个单线程的线程池。如果因异常结束,会再创建一个新的,保证按照提交顺序执行。

newFixedThreadPool:创建固定大小的线程池。根据提交的任务逐个增加线程,直到最大值保持不变。如果因异常结束,会新创建一个线程补充。

newCachedThreadPool:创建一个可缓存的线程池。会根据任务自动新增或回 收线程。

newScheduledThreadPool:支持定时以及周期性执行任务的需求。

newWorkStealingPool:JDK8 新增,根据所需的并行层次来动态创建和关闭 线程,通过使用多个队列减少竞争,底层使用 ForkJoinPool 来实现。优势在于可以充分利用多 CPU,把一个任务拆分成多个“小任务”,放到多个处理器核 心上并行执行;当多个“小任务”执行完成之后,再将这些执行结果合并起来即 可。

虽然在 JDK 中提供 Executors 类来支持以上类型的线程池创建,但通常情况下不建议开发人员直接使用(见《阿里巴巴 java 开发规范》)。

ThreadPoolExecutor 类

java.uitl.concurrent.ThreadPoolExecutor 类是线程池中最核心的一个类, 因此如果要透彻地了解 Java 中的线程池,必须先了解这个类。

    public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue);

    public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory);

    public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              RejectedExecutionHandler handler);

    public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler);

ThreadPoolExecutor 继承了 AbstractExecutorService 类,并提供了四个构造器,事实上,通过观察每个构造器的源码具体实现,发现前面三个构造器都是调用的第四个构造器进行的初始化工作。

构造器中各个参数的含义

corePoolSize:核心池的大小,这个参数跟后面讲述的线程池的实现原理有非常大的关系。在创建了线程池后,默认情况下,在创建了线程池后,线程池中的 线程数为 0,当有任务来之后,就会创建一个线程去执行任务,当线程池中的线程数目达到 corePoolSize 后,就会把到达的任务放到缓存队列当中;除非调用了prestartAllCoreThreads()或者 prestartCoreThread()方法,从这 2 个方法的名字就可以看出,是预创建线程的意思,即在没有任务到来之前就创建 corePoolSize 个线程或者一个线程。

maximumPoolSize:线程池最大线程数,这个参数也是一个非常重要的参数, 它表示在线程池中最多能创建多少个线程;

keepAliveTime:表示线程没有任务执行时最多保持多久时间会终止。默认情 况下,只有当线程池中的线程数大于 corePoolSize 时,keepAliveTime 才会起作用,直到线程池中的线程数不大于 corePoolSize,即当线程池中的线程数大于 corePoolSize 时,如果一个线程空闲的时间达到 keepAliveTime,则会终止,直到线程池中的线程数不超过 corePoolSize。

unit:参数 keepAliveTime 的时间单位,有 7 种取值,在 TimeUnit 类中有 7 种静态属性:

在这里插入图片描述

workQueue:一个阻塞队列,用来存储等待执行的任务,这个参数的选择也很重要,会对线程池的运行过程产生重大影响

threadFactory:线程工厂,主要用来创建线程;

handler:表示当拒绝处理任务时的策略

线程池的执行

创建完成 ThreadPoolExecutor 之后,当向线程池提交任务时,通常使用 execute 方法。

execute 方法的执行流程图如下:

在这里插入图片描述

  1. 如果线程池中存活的核心线程数小于线程数 corePoolSize 时,线程池会创 建一个核心线程去处理提交的任务。
  2. 如果线程池核心线程数已满,即线程数已经等于 corePoolSize,一个新提交 的任务,会被放进任务队列 workQueue 排队等待执行。
  3. 当线程池里面存活的线程数已经等于 corePoolSize 了,并且任务队列 workQueue 也满,判断线程数是否达到 maximumPoolSize,即最大线程 数是否已满,如果没到达,创建一个非核心线程执行提交的任务。
  4. 如果当前的线程数达到了 maximumPoolSize,还有新的任务过来的话,直接采用拒绝策略处理。

线程池中的队列

线程池有以下工作队列:

ArrayBlockingQueue:有界队列,是一个用数组实现的有界阻塞队列,按 FIFO 排序量。

LinkedBlockingQueue:可设置容量队列,基于链表结构的阻塞队列,按 FIFO 排序任务,容量可以选择进行设置,不设置的话,将是一个无边界的阻塞队列, 最大长度为 Integer.MAX_VALUE,吞吐量通常要高于 ArrayBlockingQuene;

线程池拒绝策略

构造方法的中最后的参数 RejectedExecutionHandler 用于指定线程池的拒绝策略。当请求任务不断的过来,而系统此时又处理不过来的时候,我们就需要采 取对应的策略是拒绝服务。

默认有四种类型:

AbortPolicy 策略:该策略会直接抛出异常,阻止系统正常工作。

CallerRunsPolicy 策略:只要线程池未关闭,该策略在调用者线程中运行当前的任务(如果任务被拒绝了,则由**提交任务的线程(例如:main)**直接执行此任务)。DiscardOleddestPolicy 策略:该策略将丢弃最老的一个请求,也就是即将被执行的任务,并尝试再次提交当前任务。

DiscardPolicy 策略:该策略丢弃无法处理的任务,不予任何处理。

execute 与 submit 的区别

执行任务除了可以使用 execute 方法还可以使用 submit 方法。它们的主要区别是:execute 适用于不需要关注返回值的场景,submit 方法适用于需要关注返回值的场景。

关闭线程池

关闭线程池可以调用 shutdownNow 和 shutdown 两个方法来实现。

shutdownNow:对正在执行的任务全部发出 interrupt(),停止执行,对还未开始执行的任务全部取消,并且返回还没开始的任务列表。

shutdown:当我们调用 shutdown 后,线程池将不再接受新的任务,但也不会去强制终止已经提交或者正在执行中的任务。

MyTask

/*
  任务
 */
public class MyTask implements Runnable {

    private int taskNum;

    public MyTask(int num) {
        this.taskNum = num;
    }

    @Override
    public void run() {
        try {
            Thread.currentThread().sleep(4000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName()+":task "+taskNum+"执行完毕");
    }
}

Test

public class Test {
    public static void main(String[] args) {
        //创建线程池                                 7
        ThreadPoolExecutor executor = new ThreadPoolExecutor(2,
                                          5, 200,
                                                         TimeUnit.MILLISECONDS,
                                                         new ArrayBlockingQueue<>(2),
                                                         Executors.defaultThreadFactory(),
                                                         new ThreadPoolExecutor.CallerRunsPolicy());
        for(int i=1;i<=8;i++){
            MyTask myTask = new MyTask(i);
            executor.execute(myTask);//添加任务到线程池
            //Future<?> submit = executor.submit(myTask);
        }
        executor.shutdown();
    }
}

ThreadLocal

概述

线程封闭

在了解ThreadLocal之前,我们先了解下什么是线程封闭。

对象封闭在一个线程里,即使这个对象不是线程安全的,也不会出现并发安全问题。

例如 栈封闭:就是用栈来保证线程安全

public void testThread() { 
    StringBuilder s = new StringBuilder(); 
    s.append("Hello"); 
}

StringBuilder 是线程不安全的,但是它只是个局部变量,局部变量存储在虚拟机栈虚拟机栈是线程隔离的,所以不会有线程安全问题.

ThreadLocal 线程封闭:简单易用

使用ThreadLocal来实现线程封闭,线程封闭的指导思想是封闭,而不是共享。

所以说ThreadLocal是用来解决变量共享的并发安全问题,多少有些不精确。

ThreadLocal是什么

从名字我们就可以看到 ThreadLocal 叫做线程变量,意思是 ThreadLocal 中填充的变量属于当前线程,该变量对其他线程而言是隔离的。ThreadLocal 为变量 在每个线程中都创建了一个副本,那么每个线程可以访问自己内部的副本变量。

ThreadLocal原理分析

首先ThreadLocal 是一个泛型类,保证可以接受任何类型的对象。

因为一个线程内可以存在多个 ThreadLocal 对象,所以其实是 ThreadLocal 内部维护了一个Map,这个 Map 不是直接使用HashMap ,而是 ThreadLocal 实现的一个叫做 ThreadLocalMap 的静态内部类。而我们使用的get()、set() 方法其实都是调用了这个ThreadLocalMap 类对应的 get()、set()方法

creatMap方法

    void createMap(Thread t, T firstValue) {
        t.threadLocals = new ThreadLocalMap(this, firstValue);
    }

ThreadLocalMap 是个静态的内部类

static calss ThreadLocalMap{
    ......
}

set方法

    public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
    }

get方法

    public T get() {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        return setInitialValue();
    }

最 终 的 变 量 是 放 在 了 当 前 线 程 的 ThreadLocalMap 中 , 并 不 是 存 在 ThreadLocal 上,ThreadLocal 可以理解为只是 ThreadLocalMap 的封装,传递了变量值。

ThreadLocal 内存泄漏问题

在这里插入图片描述

TreadLocalMap 使用 ThreadLocal 的弱引用作为 key,如果一个 ThreadLocal 不存在外部强引用时,Key(ThreadLocal)势必会被 GC 回收,这样就会导致 ThreadLocalMap 中 key 为 null, 而 value 还存在着强引用,只有 thead 线程退出以后,value 的强引用链条才会断掉。

但如果当前线程再迟迟不结束的话,这些 key 为 null 的 Entry 的 value 就会一直存在一条强引用链:

Thread Ref -> Thread -> ThreadLocalMap -> Entry -> value

永远无法回收,造成内存泄漏。

key 使用强引用

当 ThreadLocalMap 的 key 为 强 引 用 回 收 ThreadLocal 时 , 因 为 ThreadLocalMap 还 持 有 ThreadLocal 的 强 引 用 , 如 果 没 有 手 动 删 除 , ThreadLocal 不会被回收,导致 Entry 内存泄漏。key 使用弱引用

当 ThreadLocalMap 的 key 为 弱 引 用 回 收 ThreadLocal 时 , 由 于 ThreadLocalMap 持 有 ThreadLocal 的 弱 引 用 , 即 使 没 有 手 动 删 除 , ThreadLocal 也会被回收。当 key 为 null,在下一次 ThreadLocalMap 调用 set(),get(),remove()方法的时候会被清除 value 值。

ThreadLocal 正确的使用方法

每次使用完 ThreadLocal 都调用它的 remove()方法清除数据。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值