并发编程

目录

并发编程

多线程

为什么要使用多线程?

使用多线程带来的问题

如何理解线程安全和不安全

什么是并发编程

并发编程的根本原因

JMM Java内存模型

多线程核心根本问题

解决办法

volatile关键字

volatile底层实现原理

volatile 不能解决原子性问题

原子性

Java中的锁

悲观锁

乐观锁

如何实现乐观锁?

版本号机制

CAS算法

乐观锁存在的问题

可重入锁

读写锁

分段锁

自旋锁

共享锁/独占锁

公平锁/非公平锁

synchronized锁

如何使用synchronized?

synchronized的底层原理

synchronized同步语句块的情况

synchronized修饰方法的情况

总结

synchronized和volatile有什么区别?

synchronized锁升级的原理与实现

ReentrantLock

AQS(AbstractQueuedSynchronizer)

AQS的原理

CLH队列锁

面试题:synchronized与Lock的区别

JUC常用类

ConcurrentHashMap

存储结构

put流程

get流程

CopyOnWriteArrayList

CopyOnWriteArraySet

CountDownLatch

原理

线程池

池的概念

ThreadPoolExecutor

ThreadLocal

ThreadLocal底层实现

ThreadLocal内存泄漏问题


并发编程

多线程

即在一个程序中可以同时运行多个不同的线程来执行不同的任务,允许单个程序创建多个并行执行的线程来完成各自的任务

为什么要使用多线程?

现在多核CPU的普及意味着多个线程可以同时运行,减少了线程上下文的切换,整体提高了程序的响应速度,提升了CPU的利用率

使用多线程带来的问题

并发编程是为了提高程序执行的效率,但是并发编程可能会导致一些问题,例如线程死锁、内存泄漏、线程不安全等

如何理解线程安全和不安全

  • 线程安全指的是多线程环境下,对于同一份数据,不管有多少个线程访问,都能保证这个数据的正确性和一致性

  • 线程不安全指的是多线程环境下,对于同一份数据,多个线程访问可能会导致数据混乱、错误或者丢失

什么是并发编程

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

并发:在一段时间内,多个事情交替执行

并发编程:在例如买票,抢购,秒杀等场景下,有大量的请求访问同一个资源

会出现线程安全问题,所以需要通过编程来控制解决让多个线程依次访问资源,称为并发编程

并发编程的根本原因

多核CPU

JMM Java内存模型

Java内存模型是Java虚拟机规范的一种工作模式,开发者可以利用这些规范更方便的开发多线程程序,对于我们开发者来说,不需要了解其底层原理,只用一些关键字或者类来保证并发安全即可

将内存分为主内存工作内存

变量数据存储在主内存中,线程在操作变量时,会将主内存中的数据复制一份到工作内存,在工作内存中操作完成后,再写回到主内存中,这样就可能导致一个线程在主内存中已经修改了变量,而另一个线程还在操作其副本,造成数据的不一致

多线程核心根本问题

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

不可见性:多个线程分别同时对共享数据操作,彼此之间不可见,操作完写回主内存,可能会出现问题

无序性:为了性能,对一些代码指令的执行顺序重排,以提高速度

int a = 从硬盘上实时读;

int b = 5;

int c = a+b;

非原子性:i++

int i = 0; 本来应该是2 结果是1 主内存

i++ i=0 i=1 工作内存

i++ i=0 i=1 工作内存

i++ 先从主内存加载数据到工作内存

操作数据

再从工作内存 写回到 主内存

缓存(工作内存)带来了不可见性

指令重排优化 带来了无序性

线程切换 带来了非原子性

解决办法

让不可见 变为 可见

让无序 变为 不乱序/不重排/有序

非原子 变为 原子(加锁) 由于线程切换执行导致的

  • 可见性:当一个线程对共享变量进行修改,那么另外一个线程可以立刻看见新的值

  • 有序性:由于指令重排序问题,代码的执行顺序未必就是写代码时候的顺序

  • 原子性:一次或者多次操作,要么所有操作一起执行不受任何干扰中断,要么都不执行

volatile关键字

volatile 修饰的变量被一个线程修改后,可以在其他线程中立即可见,指示JVM,这个变量是共享且不稳定的,每次使用它都要去主内存中进行读取

volatile 修饰的变量,在执行的过程中不会被重排序执行,在对这个变量进行读写操作的时候,会插入特定的内存屏障的方式禁止指令重拍

volatile底层实现原理

在底层指令级别来进行控制

volatile 修饰的变量在操作前,添加内存屏障,不让它的指令干扰

volatile 修饰的变量添加内存屏障之外,还要通过缓存一致性协议(MESI)将数据写回到主内存,其他工作内存嗅探后,把自己工作内存数据过期,重新从主内存读取最新的数据

volatile 不能解决原子性问题

比如我们要实现一个i++的操作,这个操作是一个复合操作,主要有三步:

  1. 读取i的值

  2. 对i加1

  3. 将i的值写回内存

volatile是无法保证这三个操作的原子性的,有可能导致以下情况:

  1. 线程1对i的值进行读取之后还未进行修改,此时线程2也来读取i的值并对其进行修改(+1),再将i的值写回内存

  2. 线程2操作完后,线程1对i的值加1,再将i的值写回内存

这样本来最终结果应该是2,结果是1

原子性

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

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

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

在Java中还提供了一些原子类,在低并发情况下使用,是一种无锁实现,例如AtomicInteger

采用CAS(Compare-And-Swap) 比较并交换)是一种无锁实现,在低并发情况下使用

Java中的锁

有很多锁并不全指锁,一些锁的名词指的是锁的状态、特性、设计

悲观锁

悲观的认为不加锁的并发操作一定会出现问题,共享资源每次只给一个线程使用,其他线程阻塞,用完后再把资源给其他线程,例如synchronizedReentrantLock等独占锁

特点:适用于写操作多的情况

缺点:高并发下,会导致大量线程阻塞,增加系统性能开销,可能会出现死锁问题

乐观锁

认为并发情况下,共享资源被操作是不会出现问题的,线程无需加锁也无需等待,可以一直执行下去,每次操作前判断CAS(自旋)是否成立,是不加锁的实现

例如Java 中java.util.concurrent.atomic包下面的原子变量类(AtomicInteger等)就是使用了乐观锁的CAS方式实现的

特点:适用于读操作多的情况

优点:不存在线程阻塞和线程死锁,性能上往往更好一些

缺点:如果冲突频繁发生,会频繁失败和重试,导致CPU占用率飙升

如何实现乐观锁?

一般使用版本号机制或者CAS算法

版本号机制

在数据表上加一个 version 字段,表示数据被更改的次数。当数据被更改时, version 的值会加1。当线程A要更新数据时,在读取数据时也会读取 version 值,在提交更新时,若读取的 version 值和数据库的一样才更新,否则重试更新操作,直到更新成功

CAS算法

CAS全称Compare And Swap(比较与交换),实现思想很简单,用一个预期值和要更新的变量值进行比较,两值相等才会进行更新

CAS是原子操作,也就是说一旦开始,不能打断,直到操作结束

CAS 涉及到三个操作数:

  • V:要更新的值(val)

  • E:预期值

  • N:要写入的新值

当且仅当V的值等于E时,CAS用N值更新V值,如果不等,则当前线程放弃更新

举个例子:线程 A 要修改变量 i 的值为 6,i 原值为 1(V = 1,E=1,N=6,假设不存在 ABA 问题)。

  1. i 与 1 进行比较,如果相等, 则说明没被其他线程修改,可以被设置为 6 。

  2. i 与 1 进行比较,如果不相等,则说明被其他线程修改,当前线程放弃更新,CAS 操作失败。

当多个线程同时使用CAS更新值,只有一个会成功,其他的都是失败,但是只是被告知失败,会允许再次尝试,当然也允许失败的线程放弃尝试

乐观锁存在的问题

ABA问题

如果一个变量V初始读到的值A,再次赋值的时候发现还是A,并不能说明其没有被其他线程修改过,有可能是修改过后再次修改为A,那么CAS会误认为没有修改,这就是ABA问题

解决思路是在变量前面追加上版本号或者时间戳,例如AtomicStampedReference其中的 compareAndSet() 会先检查预期值和当前值是否相等,并且当前标志是否等于预期标志,如果全部相等就会更新新值

可重入锁

当一个线程获取到外部方法的同步锁对象后 依然可以获取到其内部的同步锁对象 可以在一定程度上避免线程死锁

synchronized和ReentrantLock都是可重入锁

public class Demo {
    synchronized void setA() {
        System.out.println("方法A");
        setB();
    }
​
    synchronized void setB() {
        System.out.println("方法B");
    }
    
    public static void main(String[] args) {
        Demo demo = new Demo();
        demo.setA();// 方法A 方法B
    }
}

读写锁

ReentrantReadWriteLock

读写都可以加锁

读读不互斥 读写互斥 写写互斥

如果加读锁是为了防止在写数据时读数据 从而造成读脏数据

分段锁

不是锁 而是一种思想 将数据分段 在每段上单独加锁 提高效率

自旋锁

不是锁 以自旋的方式重新获取锁 由此可见 自旋锁是很消耗CPU的 因为要不断的重复尝试

共享锁/独占锁

共享锁:可被多个线程所共有 读写锁中的读锁就是共享锁

读读不互斥 共享

互斥锁:synchronized和ReentrantLock属于独占锁

只能被一个线所持有

公平锁/非公平锁

公平锁: 按照先来后到的顺序或得到锁 比如ReentrantLock就可以实现公平锁,上下文切换繁琐

非公平锁:没有顺序 谁先抢到谁获得执行权 ReentrantLock可以是非公平锁 synchronized一定是非公平锁,性能较好,但是可能导致某些线程永远获取不到锁

synchronized锁

synchronized是Java中的一个关键字,主要解决多个线程之间访问线程的同步性,可以保证被它修饰的方法或者代码块在任意时刻只有一个线程执行

如何使用synchronized?

  1. 修饰实例方法(锁当前对象实例)

  2. 修饰静态方法(锁当前类)

  3. 修饰代码块(锁指定对象或类)

synchronized的底层原理

synchronized同步语句块的情况
public class Demo {
    public void method() {
        synchronized (this) {
            System.out.println("synchronized 代码块");
        }
    }
}

通过jdk自带的一些命令可以了解到:synchronized 同步语句块的实现使用的是 monitorentermonitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。

字节码包含一个monitorenter和两个monitorexit,是为了保证同步代码块在异常和正常执行完毕都可以被正确释放

在执行monitorenter时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。如果获取对象锁失败,该线程就要阻塞等待

在执行monitorexit时,会判断是否是对象锁的拥有者,是的话才会将锁计数器设为0,不是的话则释放失败

synchronized修饰方法的情况
public class Demo2 {
    public synchronized void method() {
        System.out.println("synchronized 方法");
    }
}

修饰方法是会有一个标志 ACC_SYNCHRONIZED ,JVM通过该标志来辨别一个方法是否是同步方法

总结

二者本质都是对对象监视器 monitor 的获取。

synchronized和volatile有什么区别?

  • volatile关键字是线程同步的轻量级实现,性能比synchronized好,但是volatile只能修饰变量,synchronized可以修饰方法和代码块

  • volatile可以保证数据的可见性,但不能保证原子性,synchronized可以两者都保证

  • volatile保证多个线程下数据的可见性,synchronized保证多个线程下访问资源的同步性

synchronized锁升级的原理与实现

synchronized锁的底层实现中 又有锁的不同状态 用来区别对待

这个锁的状态在同步锁对象的对象头中 有一个区域叫Mark Word中存储

  1. 无锁状态:对于共享资源,不存在多线程竞争访问

  2. 偏向锁状态:一直被一个线程访问 记录线程的id,下次访问直接比对id快速获取锁

  3. 轻量级锁状态:当多个线程同时申请共享资源,产生了竞争关系,JVM会使用轻量级锁 其他线程会采用自旋的方式获取锁 获得不到锁的线程不会阻塞 提高效率

  4. 重量级锁状态:当锁是轻量级锁时 其他线程采取自旋方式获得锁 自旋到一定次数时 还没获得锁 就会阻塞 该锁变为重量级锁 等待操作系统调度

注意:无锁到偏向锁并不是升级,而是开启偏向锁,偏向锁未开启,会直接从无锁到轻量级锁状态

ReentrantLock

实现了Lock接口,是一个可重入独占锁,和synchronized类似,但是增加了轮询、超时、中断、公平锁和非公平锁功能,更加灵活。

ReentrantLock默认非公平,也可以通过构造器实现公平锁

ReentrantLock lock1 = new ReentrantLock(true);//true-公平实现,false-非公平实现

非公平

NonfairSync
final void lock() {
    if (compareAndSetState(0, 1))// 线程来到后 直接尝试获取锁 是非公平的
        setExclusiveOwnerThread(Thread.currentThread());
    else// 获取不到
        acquire(1);
}

公平实现

FairSync
final void lock() {
            acquire(1);
        }

底层是由AQS来实现的

AQS(AbstractQueuedSynchronizer)

抽象队列是juc其他锁实现的基础

AQS的原理

如果被请求的资源空闲,则将当前线程设为工作线程,并且将请求资源设为锁状态,如果请求资源已被占用,那么就需要线程阻塞等待被唤醒资源分配的机制,这个机制是用CLH队列锁实现的

CLH队列锁

是一个虚拟的双向队列,不存在实例,只是有结点之间的相互关系。AQS会将线程封装成CLH锁队列中的节点Node,在此队列中,一个节点代表一个线程,其中保存着线程的引用(thread)、当前节点的状态(waitStatus)、前驱(prev)后继(next)

思路

在类中维护一个state变量,然后还维护一个队列,以及获取锁,释放锁的方法

内部有一个state变量表示锁是否使用 初始化为0 当有线程在使用时,就将state加1 其他来的线程进入到抽象队列中 当线程执行完毕释放资源时, state减1 变为0 那队列中的线程遵循FIFO的原则再去获取

面试题:synchronized与Lock的区别

  1. synchronized是关键字,是JVM底层实现;Lock是一个接口,依赖于API

  2. synchronized会自动释放锁,而Lock必须手动释放锁

  3. synchronized是不可中断的,Lock可以中断也可以不中断

    • 可中断锁:不需要一直等待锁被获取,可以中断获取过程去做其他事情

    • 不可中断锁:一旦线程申请锁,必须等到获取到锁后,才可以去进行其他的逻辑处理

  4. 通过Lock可以知道线程有没有拿到锁,因为Lock有返回值,而synchronized不能

  5. synchronized能锁住方法和代码块,Lock只能锁住代码块

  6. synchronized是非公平锁,ReentrantLock可以实现是否公平

JUC常用类

ConcurrentHashMap

线程安全的HashMap 效率比Hashtable高 因为ConcurrentHashMap是分段加锁 并不是整个方法加锁

Hashtable和ConcurrentHashMap的键和值都不能为null

存储结构

Node数组+链表/红黑树,初始化是通过自旋CAS操作完成的

put流程
  1. 根据key计算出哈希值

  2. 判断是否需要初始化

  3. 为当前key定位出Node,如果桶为空利用CAS写入,失败则自旋保证成功

  4. 如果当前位置 hashcode == MOVED == -1,则需要扩容

  5. 如果都不满足,则利用synchronized锁写入数据

  6. 如果链表长度大于8并且数组长度大于64就转为红黑树

get流程
  1. 根据hash值计算位置

  2. 查找到指定位置,如果头结点就是找的,直接返回value

  3. 如果头结点hash<0,则说明正在扩容或者是红黑树,用find方法查找

  4. 如果是链表,直接遍历查找

为什么不能put(null)?

为了消除歧义

如果put null的话,当我们使用get方法时,获取到的null无法分清是没有这个key还是这个key的值为null 这在多线程里是模糊不清的

CopyOnWriteArrayList

核心是写时复制(Copy-On-Write)

ArrayList是线程不安全的 Vector是线程安全的 但是Vector在读的时候也加了锁 所以读的时候效率会很慢

而CopyOnWriteArrayList在写的时候加了ReentrantLock锁 但在读时候并没有加锁 所以其效率比较高

在进行add,set等修改操作时,先将数据进行备份,对备份的数组进行修改,之后将修改后的数据赋值给原数组.

CopyOnWriteArraySet

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

CountDownLatch

辅助类 允许一个线程等待其他线程执行完毕再执行 底层实现还是通过AQS来完成的

原理

当线程使用 countDown() 方法时,其实使用了tryReleaseShared方法以 CAS 的操作来减少 state,直至 state 为 0 。当调用 await() 方法的时候,如果 state 不为 0,那就证明任务还没有执行完毕,await() 方法就会一直阻塞,也就是说 await() 方法之后的语句不会被执行。直到count 个线程调用了countDown()使 state 值被减为 0,或者调用await()的线程被中断,该线程才会从阻塞中被唤醒,await() 方法之后的语句得到执行。

package com.zl.javapro.thread.sync;

import java.util.concurrent.CountDownLatch;

public class CountDownLatchDemo {

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

        CountDownLatch countDownLatch = new CountDownLatch(6);//设置线程总量

        for (int i = 0; i < 6 ; i++) {
           new Thread(()->{ 
               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
               System.out.println("aaaaaaaaaaaaa");
               countDownLatch.countDown();
           }).start();
        }
        countDownLatch.await();
        System.out.println("main线程执行");//最后执行的内容

    }
}

线程池

数据库连接池、字符串常量池等,用完不会立刻销毁,而是等待下一个任务的到来

池的概念

频繁的创建数据连接对象,销毁,时间上开销较大

在JDK5之后,提供线程池的实现

使用ThreadPoolExecutor类实现线程池的创建管理

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

ThreadPoolExecutor

7个参数:

corePoolSize:核心线程池的大小

maximumPoolSize:线程池最大数量

keepAliveTime:非核心线程池中的线程,没有任务执行时保持多久时间会终止

unit:为keepAliveTime设定单位

workQueue:一个阻塞队列,用来储存等待执行的任务

ArrayBlockingQueue 有界的阻塞队列,必须给定最大容量

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

handler:拒绝策略 核心线程池,阻塞队列,非核心线程池已满,继续有任务,如何执行.

AbortPolicy(); 抛出异常,拒绝执行.

DiscardOldestPolicy(); 丢弃等待时间最长的任务.

DiscardPolicy(); 直接丢弃,不执行.

CallerRunsPolicy(); 交由当前提交任务的线程执行.

线程执行流程图

execute 与 submit 的区别

都是提交任务的,execute方法可以没有返回值

submit可以有返回值

关闭线程池

shutdownNow :关闭线程池,强制关闭

shutdown:关闭线程池,拒绝新的任务,不会强制关闭,等待任务执行完再关闭

为什么阿里巴巴不允许使用 Executors创建线程池

  • FixedThreadPoolSingleThreadExecutor:使用的是无界的 LinkedBlockingQueue,任务队列最大长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM。

  • CachedThreadPool:使用的是同步队列 SynchronousQueue, 允许创建的线程数量为 Integer.MAX_VALUE ,如果任务数量过多且执行速度较慢,可能会创建大量的线程,从而导致 OOM。

  • ScheduledThreadPoolSingleThreadScheduledExecutor : 使用的无界的延迟阻塞队列DelayedWorkQueue,任务队列最大长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM。

ThreadLocal

本地线程变量,可以为每个线程创建一个变量副本,使得多个线程相互隔离不影响

如果创建一个ThreadLocal变量,那么访问这个变量的时候每个线程都会创建一个本地副本,进行操作也是对副本的操作,从而避免线程的安全问题

ThreadLocal底层实现

看了ThreadLocal 底层源码,了解到最终的变量是放到当前线程的ThreadLocalMap 中,并不是存在ThreadLocal 上,ThreadLocal 可以理解为是ThreadLocalMap的封装

为每个当前线程创建一个ThreadLocalMap,唯一的ThreadLocal对象作为key,Object为value

ThreadLocal内存泄漏问题

因为ThreadLocal与弱引用有关,key失效后,value还被强引用着,造成内存泄漏

static class Entry extends WeakReference<ThreadLocal<?>> {
    /** The value associated with this ThreadLocal. */
    Object value;
​
    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}

调用 set()get()remove() 方法的时候,会清理掉 key 为 null 的记录。正确用法,用完之后,及时调用remove()方法清除

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

重开之Java程序员

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值