并发编程

什么是并发编程

并行:在同一个时间节点上,多个线程同时执行(是真正意义上的同时执行)

并发:一个时间段内,多个线程依次执行。

并发编程:在例如买票、抢购、秒杀等等场景下,有大量的请求访问同一个资源。会出现线程安全的问题,所以需要通过编程来解决多个线程依次访问资源,称为并发编程。

并发编程的根本原因:

  1. 多核cpu的出现,真正意义上可以做到并行执行
  2. java内存模型(JMM)

java内存模型,规范了Java虚拟机与计算机内存是如何协同工作的。

将内存分为主内存和工作内存。两个线程同时操作,会导致出错,本质原因在于内存模型设计。

并发编程_第1张图片

共享数据存储在主内存中,每个线程都有各自的工作内存。操作共享数据时,会将主内存中的数据复制一份到工作内存中操作,操作完成后,再写回到主内存中。

但是一旦两个线程同时进行操作,读取共享数据,两个线程各自在工作内存中修改后,同时又写到主内存,这样就会与预期的结果不同。(AB两个线程同时操作变量n)

一、并发编程核心问题

由于java内存模型的设计,多线程操作一些共享的数据时,出现以下3个问题:

(1)不可见性:A线程在工作内存中操作共享数据时,B线程不知道A线程已经修改了数据。

(2)无序性:为了优化性能,有时候会改变程序中语句的先后顺序,以提高速度。

int a = 10;

io.read();//从其他地方读数据

int b = 5;

int c=a+b;

但是为了优化,第2行需要从其他地方读数据 需要时间;系统可能将3行代码乱序执行,例如 1、3、2的顺序执行。

有时,看似没有关系的代码乱序执行,可能会对后面的代码产生影响。

(3)非原子性

一个或多个操作在CPU执行的过程中不被中断的特性,我们称为原子性。 原子性是拒绝多线程交叉操作的,同一时刻只能有一个线程来对它进行操作

高级语言里一条语句往往需要多条CPU指令完成。如 count++,至少需要三条CPU指令。

  • 首先,需要把变量 count 从主内存加载到工作内存;
  • 之后,在工作内存执行 +1 操作;
  • 最后,将结果写入主内存;

解决办法

  1. 让不可见变为可见
  2. 让无序变为有序
  3. 非原子执行变为原子(加锁),由于线程切换执行导致

缓存(工作内存) 带来了不可见性;指令重排优化带来了无序性;线程切换带来了非原子性。

volatile可以解决前两个问题,加锁可以解决所有问题。

二、volatile关键字

volatile修饰的共享变量(类的成员变量、类的静态成员变量),被一个线程修改后,可以同步更新到其他线程,让其他线程中立即可见。volatile修饰的共享变量,指令是有顺序的。

但是volatile不能解决原子性问题,原子性问题由于线程切换执行导致。

volatile底层实现原理:

使用内存屏障(指令)进行控制。

  • 有序性实现:volatile修饰的变量,在操作前添加内存屏障,来禁止指令重排序。
  • 可见性实现:volatile修饰的变量添加内存屏障之外,还通过缓存一致性协议(MESI)将数据写回到主内存,其他工作内存嗅探后,如果自己工作内存中的数据过期,重新从主内存读取最新的数据。

三、如何保证原子性

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

1、锁

只有通过加锁的方式,让线程互斥执行,来保证一次只有一个线程对共享资源进行访问

synchronized:关键字;修饰代码块、方法;自动获取锁,自动释放锁

ReentrantLock:类;只能对某段代码修饰;需要手动加锁,手动释放锁

2、原子变量

在java中还提供一些原子类,在低并发情况下使用,是一种无锁实现。

JUC(java.util.concurrent包)中,里面的locks包和atomic包,它们可以解决原子性问题。

1.原子类原理(AtomicInteger 为例)

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

低并发情况下:使用原子类 AtomicInteger,底层有一个变量通过volatile关键字修饰的,结合CAS机制实现。

2.CAS(重点)

采用CAS机制(Compare-And-Swap比较并交换),是一种无锁实现,在低并发情况下使用。CAS是乐观锁的方式,采用的是自旋的思想。

采用自旋思想:

(1)第一次从内存中读到内存值V

(2)对数据进行修改,将改变后的值写入到内存时,需要重新读取内存中最新的值,作为预期值A

(3)在写入前比较预期值与内存值,看是否一致:

  • 如果一致,说明其他线程没有修改内存中的值,将更新后的值,写入到内存;
  • 如果不一致,说明其他线程修改了主内存中的值,就需要重新计算变量值,反复这一过程。--->自旋

并发编程_第2张图片

优点:

  • 不加锁,所有的线程都可以对共享数据操作;
  • 适合低并发使用,因为所有线程不会进入阻塞状态

缺点:

  • 大并发时,不停自旋判断,导致cpu占用率高
3.ABA问题

并发编程_第3张图片

ABA问题,即线程1读取到内存值,线程2将内存值由A改为了B,再由B改为了A。当线程1去判断时,预期值与内存值相同,无法分辨内存值是否发生过变化。

通过设置版本号,每次操作改变版本号来避免ABA问题。如原先的内存值为(A,1),线程修改为(B,2),再修改为(A,3)。此时另一个线程使用预期值(A,1)与内存值(A,3)进行比较,只需要比较版本号1和3,即可发现该主内存中的数据被更新过了。

四、java中的锁

一些锁的名称指的是锁的特性、设计、状态,并不是都是锁。

1、乐观锁/悲观锁

乐观锁:没有加锁,不加锁的方式是没有问题的。例如CAS机制

悲观锁:必须加锁。悲观的认为,不加锁的并发操作一定会出问题。

2、可重入锁

synchronized和ReentrantLock是可重入锁,可以避免死锁。

并发编程_第4张图片

A方法和B方法是两个同步方法,在同一个类中,用同一把锁,先进入到同步方法A中,锁被使用,在方法A调用方法B依然可以进入到方法B。(此时方法A还没有释放锁)

如果不是可重入锁的话,方法B不会被当前线程执行。

3、读写锁

ReentrantReadWriteLock,里面有一个读锁和写锁。

  • 读读不互斥:只有读没有写,可以多个线程同时读
  • 读写互斥:一旦有写操作,读写不同同时进行。
  • 写写互斥:多个写互斥
4、分段锁

不是锁,是一种锁实现思想:用于将数据分段,并在每个分段上都会单独加锁,以提高并发效率。

举例:Hashtable是将整合hash表格锁住了,一次只能有一个线程操作并发量低,效率低。

ConcurrentHashMap将每个哈希位置当做一个锁,可以有多个线程对map进行操作,一次只能有一个线程操作一个位置.

5、自旋锁

不是锁。是自己重试,当线程抢锁失败后,重试几次,如果抢到锁了就继续,如果抢不到就阻塞线程。

6、共享锁/独占锁

共享锁:一个锁可被多个线程共享,例如读写锁中的 读锁。

独占锁:一次只能有一个线程操作。例如:Synchronized、ReentrantLock,读写锁中的 写锁。

7、公平锁/非公平锁

公平锁:按照请求的顺序执行(排队,先来来执行)。

非公平锁:不按照请求顺序执行,谁先抢到谁先执行。

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

五、synchronized锁

1、锁的状态

synchronized锁的底层实现中,提供4种锁的状态,又来区别对待。(锁的状态在同步锁对象的对象头中,有一个区域叫Mark Word中存储)

  1. 无锁状态:没有线程进入。
  2. 偏向锁:始终只有一个线程访问同步代码快,记录线程的编号,快速的获取锁。
  3. 轻量级锁:当锁状态为偏向锁时,还有其他线程访问,此时升级为轻量级锁。特点:当一个线程获取锁之后,其他线程不会阻塞,会通过自旋方式获取锁,提高效率。
  4. 重量级锁:当锁的状态为轻量级锁时,线程自旋达到一定的次数,还没有获取到锁,就会进入到阻塞状态,锁状态升级为重量级锁,等待操作系统调度。

2、对象结构

在Hotspot虚拟机中,对象在内存中分为三块区域:对象头、实例数据和对齐填充;synchronized使用的锁对象是存储在对象头里。

并发编程_第5张图片

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

32位操作系统Mark Word为32bit,64 位操作系统Mark Word为64bit。下面就是对象头的一些信息:

并发编程_第6张图片

3、synchronized锁实现

synchronized锁是依赖底层编译后的指令,添加锁的监视器实现,需要我们提供一个同步对象,来记录是否加锁、以及锁的状态。

六、AQS

全称为(AbstractQueuedSynchronizer),这个类在java.util.concurrent.locks包下面。抽象同步队列,是java代码实现线程同步非常重要的一个底层实现类。

思路:

  • 在类中定义了一个state变量(初始化为0,表示有没有线程访问共享资源)和一个双向链表队列(head结点代表当前占用的线程)。
  • 有线程访问时,第一个抢到执行权的线程放在头节点,将state加1。期间如果有其他的线程访问时,如果state=1,将其他线程添加到队列中,等待锁的释放。

state由于是多线程共享变量,所以定义成volatile,以保证state的可见性,但不能保证原子性,所以AQS提供了对state的原子操作方法,保证了线程安全。

队列由Node对象组成,Node是AQS中的内部类。

并发编程_第7张图片

AQS 的锁模式分为:独占和共享

独占锁:每次只能有一个线程持有锁,比如ReentrantLock是以独占方式实现的。

共享锁:允许多个线程同时获取锁,并发访问共享资源,比如ReentrantReadWriteLock

ReentrantLock锁实现

ReentrantLock是java.util.concurrent.locks包下的类,实现Lock接口。

public class ReentrantLock implements Lock, java.io.Serializable{ }

ReentrantLock基于AQS,在并发编程中可以实现公平锁和非公平锁来对共享资源进行同步。ReentrantLock类内部总共存在Sync、NonfairSync、FairSync三个,NonfairSync与FairSync类继承自Sync类,Sync类继承自AbstractQueuedSynchronizer抽象类。

并发编程_第8张图片

ReentrantLock构造方法

  • 无参构造方法默认是非公平实现
  • 有参构造方法可以选择,true—公平实现,false—非公平实现

并发编程_第9张图片

NonfairSync类继承了Sync类,表示采用非公平策略获取锁,其实现了Sync类中抽象的lock方法。 

static final class NonfairSync extends Sync {
//若通过 CAS 设置变量 state 成功,就是获取锁成功,则将当前线程设置为独占线程。
//若通过 CAS 设置变量 state 失败,就是获取锁失败,则进入 acquire 方法进行后续处理。
    final void lock() {
        if (compareAndSetState(0, 1))//每个线程进入到lock方法时,会尝试获取锁,有可能获取到了
            setExclusiveOwnerThread(Thread.currentThread());
        else//获取不到,将线程添加到队列中,排队获取锁
            acquire(1);
    }
	//尝试获取锁,无论是否获得都立即返回
    protected final boolean tryAcquire(int acquires) {
        return nonfairTryAcquire(acquires);
    }
}

FairSync类也继承了Sync类,表示采用公平策略获取锁,其实现了Sync类中的抽象lock方法。

static final class FairSync extends Sync {
    final void lock() {//公平锁,默认排队获取锁
        acquire(1);
    }
}

七、JUC常用类

1、ConcurrentHashMap

HashMap是线程不安全的,不能在多线程环境下使用

Hashtable是线程安全的,但是synchronized直接锁住的是整个方法,效率低(public synchronized V put(K key,V value{}))

ConcurrentHashMap是线程安全的,效率高于Hashtable。

不像Hashtable将整个方法锁起来,将每个位置的第一个节点当做锁对象,将锁的力度减小,进而提高了效率;同时可以有多个线程对ConcurrentHashMap进行操作,如果多个线程操作的是同一个位置,那么必须等待,因为用的是同一把锁。当算出的位置,第一个节点为null时,采用CAS机制添加。

并发编程_第10张图片

Hashtable和ConcurrentHashMap不支持存储null键和null值。源码中看到为null,就报空指针异常。为什么这样设计呢?

为了消除歧义,因为无法分辨key的值为null还是key不存在返回的null,这在多线程里面是模糊不清的,所以压根就不让 put null。

2、CopyOnWriteArrayList

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

Vector是线程安全的,get、add方法都加锁,读读都互斥,效率低。

CopyOnWriteArrayList在读的时候不加锁,写入也不会阻塞读取操作,只有同时写入和写入之间需要进行同步等待,提高了读的效率。

CopyOnWriteArrayList在进行add、set等修改操作时,是通过底层数组的副本实现的。先将底层数组进行复制,修改复制出来的数组,修改后将数据赋值给原来的底层数组。写入时,不影响其他线程读

3、CopyOnWriteArraySet

CopyOnWriteArraySet线程安全的,底层使用的是CopyOnWriteArrayList不能存储重复数据

4、辅助类 CountDownLatch

CountDownLatch允许一个线程 等待其他线程各自执行完毕后再执行。底层实现是通AQS来完成的,创建CountDownLatch对象时指定一个初始值(线程的数量)。每当一个线程执行完毕后,AQS内部的state就-1,当state的值为0时,表示所有线程都执行完毕,然后等待的线程就可以恢复工作了。

并发编程_第11张图片

八、对象引用

在JDK1.2版之后,Java对引用的概念进行了扩充,将引用分为:

  • 强引用
  • 软引用(SoftReference)
  • 弱引用(WeakReference)
  • 虚引用(PhantomReference)

这4种引用强度依次逐渐减弱。除强引用外,其他3种引用均可以在java.lang.ref包中找到它们的身影。

1、强引用(不是垃圾)

有引用指向该对象,Object obj = new Object(); 这种情况下new出来的对象不能被垃圾回收的。

软引用、弱引用、虚引用都是用来标记对象的一种状态。当一些对象称为垃圾后,通过不同的状态来判断什么时候被清理。可以继承SoftReference、WeakReference、PhantomReference或者把自己的对象添加到软、弱、虚的对象中。

2、软引用(内存不足时回收)

被软引用关联的对象,被判定为垃圾时,可以不用立即回收;直到垃圾回收后内存仍然不够用时,才会回收软引用关联的对象。

Object obj = new Object();// 声明强引用
SoftReference sf = new SoftReference<>(obj);
obj = null; //销毁强引用 
  

3、弱引用(发现时回收)

弱引用管理的对象,只能存活到下一次垃圾回收。

4、虚引用(对象回收跟踪)

最弱的引用,对对象的生命周期没有任何的影响,跟踪对象是否被回收(如果对象被回收后,会给队列返回信息)

Object obj = new Object();
ReferenceQueue phantomQueue = new ReferenceQueue();//声明引用队列
PhantomReference sf = new PhantomReference<>(obj,phantomQueue);//声明虚引用(还需要传入引用队列),如果对象被回收后,会给队列返回信息
obj = null; 
  

九、线程池

1、池的概念

如果并发的线程数量很多,并且每个线程都是执行一个时间很短的任务就结束了,频繁创建线程和销毁线程需要时间。 可以事先创建出一些连接对象,每次使用时,从集合中直接获取,用完不销毁。减少频繁创建、销毁。

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

池的好处:减少频繁创建销毁时间,统一管理线程,提高速度。

2、ThreadPoolExecutor类

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

并发编程_第12张图片

ThreadPoolExecutor继承了AbstractExecutorService类,并提供了四个构造器,但是前三个构造器都是调用的第四个构造器进行的初始化工作。

3、构造器中各个参数的含义

1.corePoolSize

核心池的大小,一旦创建不会被销毁的;非核心池中的线程,在没有被使用时,可以被回收。

2.maximumPoolSize

线程池最大线程数量,包含核心池中的数量。

3.keepAliveTime

非核心线程池中的线程,在不被使用后,多久就终止。(假如核心线程池5个,最大数量10,但是任务少的情况下,核心线程池够用了,等多长时间,就把非核心线程池中的线程终止)

4.unit

为keepAliveTime设置时间单位,有7种取值。

并发编程_第13张图片

5.workQueue

一个阻塞队列,用来存储执行的任务。有以下工作队列:

  1. ArrayBlockingQueue:数组实现的有界阻塞队列,创建时必须设置长度,按FIFO排序。
  2. LinkedBlockingQueue:链表结构的阻塞队列,按FIFO排序任务,容量可以选择进行设置,不设置是一个最大长度为 Integer.MAX_VALUE;
6.threadFactory

创建线程的工厂

7.handler

拒绝策略。当线程池中的核心池、阻塞队列、非核心池已满时,如果有任务继续到达,如何执行。有以下四种拒绝策略:

  1. AbortPolicy();直接抛出异常,拒绝执行。
  2. CallerRunsPolicy();交由当前提交任务的线程执行(如果任务被拒绝了,则由提交任务的线程(例如:main)直接执行此任务)
  3. DiscardOldestPolicy();丢弃等待时间最长的任务。
  4. DiscardPolicy();直接丢弃,不执行。

4、线程池的执行

创建完成ThreadPoolExecutor之后,当向线程池提交任务时,通常使用execute方法。 execute方法的执行流程图如下:

并发编程_第14张图片

当请求到来时,如果核心线程池没有满,就提交到核心线程池,如果核心线程池已满,则添加到队列中(前提是队列没有满);如果队列中已满,则在非核心线程中创建线程,直到到达最大线程数量;如果非核心线程池也已经满了,那么则使用适当的拒绝策略处理。

execute与submit的区别

  • execute() 提交任务,没有返回值
  • submit() 提交任务,可以有返回值(任务需要实现callable接口)

关闭线程池

  • shutdownNow() 直接关闭,对还未开始执行的任务全部取消
  • shutdown() 等待任务执行完关闭
//任务
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+"执行完毕");
    }
}
public class Test {
    public static void main(String[] args) {
        //创建线程池
        ThreadPoolExecutor executor = new ThreadPoolExecutor(2,
                                     5, 200,
                                     TimeUnit.MILLISECONDS,
                                     new ArrayBlockingQueue<>(2),
                                     Executors.defaultThreadFactory(),
                                     new ThreadPoolExecutor.CallerRunsPolicy());
        executor.prestartAllCoreThreads();

        for(int i=1;i<=8;i++){
            MyTask myTask = new MyTask(i);
            executor.execute(myTask);//添加任务到线程池
           //Future submit = executor.submit(myTask);
                        //submit.get();//返回值
        }
        executor.shutdown();
    }
}

十、ThreadLocal

本地线程变量,可以为每个线程都创建一个属于自己的变量副本,使得多个线程之间隔离,不影响。(在每一个线程里都有一个自己的localNum)

package com.ffyc.javapro.thread.threadlocal;

public class ThreadLocalDemo {

    //创建一个ThreadLocal对象,复制保用来为每个线程会存一份变量,实现线程封闭
    private  static ThreadLocal localNum = new ThreadLocal(){
        @Override
        protected Integer initialValue() {
            return 0;
        }
    };

    public static void main(String[] args) {
          new Thread(){
              @Override
              public void run() {
                   localNum.set(1);
                  try {
                      Thread.sleep(2000);
                  } catch (InterruptedException e) {
                      e.printStackTrace();
                  }
                  localNum.set(localNum.get()+10);
                  System.out.println(Thread.currentThread().getName()+":"+localNum.get());//11
              }
          }.start();

        new Thread(){
            @Override
            public void run() {
                localNum.set(3);
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                localNum.set(localNum.get()+20);
                System.out.println(Thread.currentThread().getName()+":"+localNum.get());//23
            }
        }.start();
        System.out.println(Thread.currentThread().getName()+":"+localNum.get());//0(main线程)
    }
}

ThreadLocal底层实现:

在一个线程中使用ThreadLocal时,为每个当前线程创建了一个ThreadLocalMap,看似用唯一的ThreadLocal对象作为键,其实每个线程中都有一个属于自己的ThreadLocalMap,所以每个线程中都有一个自己的变量副本。

ThreadLocal会造成内存泄漏:

由于ThreadLocal被弱引用关联,有可能在下一次垃圾回收时被回收掉,会导致key为null,而value还存在着强引用。但是value却被Entry对象关联,Entry又被ThreadLocalMap关联,ThreadLocalMap又被Thread关联,要是当前线程长期不结束,value就不能被销毁,但是key有可能已被回收,就获取不到value造成内存泄漏。

正确的使用:不再使用这个本地线程变量后,将其主动删除掉,调用remove方法删除。

你可能感兴趣的:(java,jvm,开发语言)