多线程与高并发干货笔记分享,造起来

开发 架构
在Java中,自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁, 这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。

[[396436]]

本文转载自微信公众号「牧小农」,作者牧小农。转载本文请联系牧小农公众号。

1. 创建线程的四种方式

  • 实现Runnable 重写run方法
  • 继承Thread 重写run方法
  • 线程池创建 Executors.newCachedThreadPool()
  • 实现Callable接口

2. Thread线程操作方法

当前线程睡眠指定mills毫秒

  • Thread.sleep([mills])

当前线程优雅让出执行权

  • Thread.yield()

例如Thread t1, t2,在t2的run方法中调用t1.join(),线程t2将等待t1完成后执行

  • join

3. Thread状态

状态 使用场景
NEW Thread被创建之后,未start之前
RUNNABLE 在调用start()方法之后,这也是线程进入运行状态的唯一一种方式。
具体分为ready跟running,当线程被挂起或者调用Thread.yield()的时候为ready
WAITING 当一个线程执行了Object.wait()的时候,它一定在等待另一个线程执行Object.notify()或者Object.notifyAll()。
或者一个线程thread,其在主线程中被执行了thread.join()的时候,主线程即会等待该线程执行完成。当一个线程执行了LockSupport.park()的时候,其在等待执行LockSupport.unpark(thread)。当该线程处于这种等待的时候,其状态即为WAITING。需要关注的是,这边的等待是没有时间限制的,当发现有这种状态的线程的时候,若其长时间处于这种状态,也需要关注下程序内部有无逻辑异常。

TIMED_WAITING

这个状态和WAITING状态的区别就是,这个状态的等待是有一定时效的
Thread.sleep(long)
Object.wait(long)
Thread.join(long)
LockSupport.parkNanos()
LockSupport.parkUntil()
BLOCKED 在进入synchronized关键字修饰的方法或代码块(获取锁)时的状态
TERMINATED 线程执行结束之后的状态。
线程一旦终止了,就不能复生。
在一个终止的线程上调用start()方法,会抛出java.lang.IllegalThreadStateException异常

4. synchronized

  • 锁住的是对象而不是代码
  • this 等价于 当前类.class
  • 锁定方法,非锁定方法同时进行
  • 锁在执行过程中发生异常会自动释放锁
  • synchronized获得的锁是可重入的
  • 锁升级 偏向锁-自旋锁-重量级锁
  • synchronized(object)不能用String常量/Integer,Long等基本数据类型
  • 锁定对象的时候要保证对象不能被重写,最好加final定义

4. volatile

  • 保证线程可见性
  • 禁止指令重排序
  • volatile并不能保证多个线程修改的一致性,要保持一致性还是需要synchronized关键字
  • volatile 引用类型(包括数组)只能保证引用本身的可见性,不能保证内部字段的可见性 volatile关 键字只能用于变量而不可以修饰方法以及代码块

5. synchronized与AtomicLong以及LongAdder的效率对比

Synchronized 是需要加锁的,效率偏低;AtomicLong 不需要申请锁,使用CAS机制;LongAdder 使用分段锁,所以效率好,在并发数量特别高的时候,LongAdder最合适

6. ConcurrentHashMap的分段锁原理

分段锁就是将数据分段上锁,把锁进一步细粒度化,有助于提升并发效率。HashTable容器在竞争激烈的并发环境下表现出效率低下的原因是所有访问HashTable的线程都必须竞争同一把锁,假如容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术。首先将数据分成一段一段地存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。

7. ReentrantLock

ReentrantLock可以替代synchronized 但是ReentrantLock必须手动开启锁/关闭锁,synchronized遇到异常会自动释放锁,ReentrantLock需要手动关闭,一般都是放在finally中关闭 定义锁 Lock lock = new ReentrantLock(); 开启 lock.lock(); 关闭 lock.unlock(); 使用Reentrantlock可以进行“尝试锁定”tryLock,这样无法锁定,或者在指定时间内无法锁定,线程可以决定是否继续等待。使用tryLock进行尝试锁定,不管锁定与否,方法都将继续执行 可以根据tryLock的返回值来判定是否锁定 也可以指定tryLock的时间,由于tryLock(time)抛出异常,所以要注意unclock的处理,必须放到finally中,如果tryLock未锁定,则不需要unlock 使用ReentrantLock还可以调用lockInterruptibly方法,可以对线程interrupt方法做出响应,在一个线程等待锁的过程中,可以被打断 new ReentrantLock(true) 表示公平锁,不带参数默认为false,非公平锁

8. CountDownLatch

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

线程中调用countDown()方法开始计数;在调用await()方法的线程中,当计数器为0后续才会继续执行,否则一直等待;也可以使用latch.await(timeout, unit)在等待timeout时间后如果计数器不为0,线程仍将继续。countDown()之后的代码不受计数器控制 与join区别,使用join的线程将被阻塞,使用countDown的线程不受影响,只有调用await的时候才会阻塞

8. CyclicBarrier

作用就是会让指定数量的(数量由构造函数指定)所有线程都等待完成后才会继续下一步行动。构造函数:public CyclicBarrier(int parties)

  1. public CyclicBarrier(int parties) 
  2.  
  3. public CyclicBarrier(int parties, Runnable barrierAction) 

parties 是线程的个数;barrierAction为最后一个到达线程要做的任务

所有线程会等待全部线程到达栅栏之后才会继续执行,并且最后到达的线程会完成 Runnable 的任务。

实现原理:在CyclicBarrier的内部定义了一个Lock对象,每当一个线程调用await方法时,将拦截的线程数减1,然后判断剩余拦截数是否为初始值parties,如果不是,进入Lock对象的条件队列等待。如果是,执行barrierAction对象的Runnable方法,然后将锁的条件队列中的所有线程放入锁等待队列中,这些线程会依次的获取锁、释放锁。

9. Phaser

可重复使用的同步屏障,功能类似于CyclicBarrier和CountDownLatch,但支持更灵活的使用。

Phaser使我们能够建立在逻辑线程需要才去执行下一步的障碍等。

我们可以协调多个执行阶段,为每个程序阶段重用Phaser实例。每个阶段可以有不同数量的线程等待前进到另一个阶段。我们稍后会看一个使用阶段的示例。

要参与协调,线程需要使用Phaser实例 register() 本身。请注意:这只会增加注册方的数量,我们无法检查当前线程是否已注册 - 我们必须将实现子类化以支持此操作。

线程通过调用 arriAndAwaitAdvance() 来阻止它到达屏障,这是一种阻塞方法。当数量到达等于注册的数量时,程序的执行将继续,并且数量将增加。我们可以通过调用getPhase()方法获取当前数量。

10. ReadWriteLock

ReadWriteLock的具体实现是ReentrantReadWriteLock

ReadWriteLock允许分别创建读锁跟写锁

  1. ReadWriteLock readWriteLock = new ReentrantReadWriteLock(); 
  2.  Lock readLock = readWriteLock.readLock(); 
  3.  Lock writeLock = readWriteLock.writeLock(); 

使用ReadWriteLock时,适用条件是同一个数据,有大量线程读取,但仅有少数线程修改。ReadWriteLock可以保证:

只允许一个线程写入(其他线程既不能写入也不能读取);

没有写入时,多个线程允许同时读(提高性能)

读写分离锁可以有效地帮助减少锁竞争,以提高系统性能,读写锁读读之间不互斥,读写,写写都是互斥的

11. Semaphore

Semaphore 是一个计数信号量,必须由获取它的线程释放。常用于限制可以访问某些资源的线程数量,例如通过 Semaphore 限流。

对于Semaphore来说,它要保证的是资源的互斥而不是资源的同步,在同一时刻是无法保证同步的,但是却可以保证资源的互斥。只是限制了访问某些资源的线程数,其实并没有实现同步。

常用方法:

1、acquire(int permits)

从此信号量获取给定数目的许可,在提供这些许可前一直将线程阻塞,或者线程已被中断。就好比是一个学生占两个窗口。这同时也对应了相应的release方法。

2、release(int permits)

释放给定数目的许可,将其返回到信号量。这个是对应于上面的方法,一个学生占几个窗口完事之后还要释放多少

3、availablePermits()

返回此信号量中当前可用的许可数。也就是返回当前还有多少个窗口可用。

4、reducePermits(int reduction)

根据指定的缩减量减小可用许可的数目。

5、hasQueuedThreads()

查询是否有线程正在等待获取资源。

6、getQueueLength()

返回正在等待获取的线程的估计数目。该值仅是估计的数字。

7、tryAcquire(int permits, long timeout, TimeUnit unit)

如果在给定的等待时间内此信号量有可用的所有许可,并且当前线程未被中断,则从此信号量获取给定数目的许可。

8、acquireUninterruptibly(int permits)

从此信号量获取给定数目的许可,在提供这些许可前一直将线程阻塞。

12. Exchanger

用于两个工作线程之间交换数据的封装工具类,简单说就是一个线程在完成一定的事务后想与另一个线程交换数据,则第一个先拿出数据的线程会一直等待第二个线程,直到第二个线程拿着数据到来时才能彼此交换对应数据。其定义为 Exchanger泛型类型,其中 V 表示可交换的数据类型,对外提供的接口很简单,具体如下:

Exchanger():无参构造方法。

V exchange(V v):等待另一个线程到达此交换点(除非当前线程被中断),然后将给定的对象传送给该线程,并接收该线程的对象。

V exchange(V v, long timeout, TimeUnit unit):等待另一个线程到达此交换点(除非当前线程被中断或超出了指定的等待时间),然后将给定的对象传送给该线程,并接收该线程的对象。

13. LockSupport

LockSupport 是一个非常方便实用的线程阻塞工具,他可以在任意位置让线程阻塞。

LockSupport 的静态方法 park()可以阻塞当前线程,类似的还有 parkNanos(),parkUntil()等,他们实现了一个限时的等待。

方法 描述
void park(): 阻塞当前线程,如果调用unpark方法或者当前线程被中断,从能从park()方法中返回
void park(Object blocker) 功能同方法1,入参增加一个Object对象,用来记录导致线程阻塞的阻塞对象,方便进行问题排查;
void parkNanos(long nanos) 阻塞当前线程,最长不超过nanos纳秒,增加了超时返回的特性;
void parkNanos(Object blocker, long nanos) 功能同方法3,入参增加一个Object对象,用来记录导致线程阻塞的阻塞对象,方便进行问题排查;
void parkUntil(long deadline) 阻塞当前线程,直到deadline;
void parkUntil(Object blocker, long deadline) 功能同方法5,入参增加一个Object对象,用来记录导致线程阻塞的阻塞对象,方便进行问题排查;

同样的,有阻塞的方法,当然有唤醒的方法,什么呢?unpark(Thread) 方法。该方法可以将指定线程唤醒。

需要注意的是:park 方法和 unpark 方法执行顺序不是那么的严格。比如我们在 Thread 类中提到的 suspend 方法 和resume 方法,如果顺序错误,将导致永远无法唤醒,但 park 方法和 unpark 方法则不会,因为 LockSupport 使用了类似信号量的机制。他为每一个线程准备了一个许可(默认不可用),如果许可能用,那么 park 函数会立即返回,并且消费这个许可(也就是将许可变为不可用),如果许可不可用,将会阻塞。而 unpark 方法则使得一个许可变为可用

14. AQS

AQS 为 AbstractQueuedSynchronizer 的简称

AQS是JDK下提供的一套用于实现基于FIFO等待队列的阻塞锁和相关的同步器的一个同步框架。这个抽象类被设计为作为一些可用原子int值来表示状态的同步器的基类。AQS管理一个关于状态信息的单一整数,该整数可以表现任何状态。比如 Semaphore 用它来表现剩余的许可数, ReentrantLock 用它来表现拥有它的线程已经请求了多少次锁;FutureTask 用它来表现任务的状态(尚未开始、运行、完成和取消)

使用须知:

Usage

  1. To use this class as the basis of a synchronizer, redefine the 
  2. following methods, as applicable, by inspecting and/or modifying 
  3. the synchronization state using {@link #getState}, {@link 
  4. #setState} and/or {@link #compareAndSetState}: 
  5. {@link #tryAcquire} 
  6. {@link #tryRelease} 
  7. {@link #tryAcquireShared} 
  8. {@link #tryReleaseShared}> 
  9. {@link #isHeldExclusively} 

以上方法不需要全部实现,根据获取的锁的种类可以选择实现不同的方法: 支持独占(排他)获取锁的同步器应该实现tryAcquire、 tryRelease、isHeldExclusively; 支持共享获取锁的同步器应该实现tryAcquireShared、tryReleaseShared、isHeldExclusively。

  • AQS浅析

AQS的实现主要在于维护一个"volatile int state"(代表共享资源)和 一个FIFO线程等待队列(多线程争用资源被阻塞时会进入此队列)。队列中的每个节点是对线程的一个封装,包含线程基本信息,状态,等待的资源类型等。

state的访问方式有三种:

getState() setState() compareAndSetState()

AQS定义两种资源共享方式

Exclusive(独占,只有一个线程能执行,如ReentrantLock) Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch) 不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源state的获取与释放方式即可, 至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。自定义同步器实现时主要实现以下几种方法:

isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。

tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。

tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。

tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。

以ReentrantLock为例

state初始化为0,表示未锁定状态。

A线程lock()时,会调用tryAcquire()独占该锁并将state+1。

此后,其他线程再tryAcquire()时就会失败,直到A线程unlock()到state=0(即释放锁)为止,其它线程才有机会获取该锁。

当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。

但要注意,获取多少次就要释放多么次,这样才能保证state是能回到零态的。

以CountDownLatch为例

任务分为N个子线程去执行,state也初始化为N(注意N要与线程个数一致)。

这N个子线程是并行执行的,每个子线程执行完后countDown()一次,state会CAS减1。

等到所有子线程都执行完后(即state=0),会unpark()主调用线程,然后主调用线程就会从await()函数返回,继续后余动作。

一般来说,自定义同步器要么是独占方法,要么是共享方式,

他们也只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。

但AQS也支持自定义同步器同时实现独占和共享两种方式,如"ReentrantReadWriteLock"。

15. 锁基本概念

公平锁/非公平锁

可重入锁

独享锁/共享锁

互斥锁/读写锁

乐观锁/悲观锁

分段锁

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

自旋锁

  • 公平锁/非公平锁

公平锁是指多个线程按照申请锁的顺序来获取锁。

非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序, 有可能后申请的线程比先申请的线程优先获取锁; 有可能会造成优先级反转或者饥饿现象。

对于Java ReentrantLock而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。

非公平锁的优点在于吞吐量比公平锁大。

对于Synchronized而言,也是一种非公平锁。由于其并不像ReentrantLock是通过AQS的来实现线程调度, 所以并没有任何办法使其变成公平锁。

  • 可重入锁

可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。

ReentrantLock, Synchronized都是可重入锁。

可重入锁的一个好处是可一定程度避免死锁

  • 独享(排他)锁/共享锁

独享锁是指该锁一次只能被一个线程所持有。

共享锁是指该锁可被多个线程所持有。

对于ReentrantLock而言,其是独享锁。

但是对于Lock的另一个实现类ReadWriteLock,其读锁是共享锁,其写锁是独享锁。读锁的共享锁可保证并发读是非常高效的,读写,写读 ,写写的过程是互斥的。独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。

对于Synchronized而言,当然是独享锁。

  • 互斥锁/读写锁

上面讲的独享锁/共享锁就是一种广义的说法,互斥锁/读写锁就是具体的实现。

互斥锁在Java中的具体实现就是ReentrantLock

读写锁在Java中的具体实现就是ReadWriteLock

  • 乐观锁/悲观锁

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

悲观锁(Synchronized 和 ReentrantLock)

认为对于同一个数据的并发操作,一定是会发生修改的,哪怕没有修改,也会认为修改。

因此对于同一个数据的并发操作,悲观锁采取加锁的形式。悲观的认为,不加锁的并发操作一定会出问题。

  • 乐观锁(java.util.concurrent.atomic包)

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

悲观锁适合写操作非常多的场景,乐观锁适合读操作非常多的场景,

不加锁会带来大量的性能提升。

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

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

  • 分段锁

分段锁其实是一种锁的设计,并不是具体的一种锁,ConcurrentHashMap并发的实现就是通过分段锁的形式来实现高效的并发操作。

ConcurrentHashMap中的分段锁称为Segment, 它类似于HashMap(JDK7与JDK8中HashMap的实现)的结构, 即内部拥有一个Entry数组,数组中的每个元素又是一个链表;同时又是一个ReentrantLock(Segment继承了ReentrantLock)。当需要put元素的时候,并不是对整个hashmap进行加锁, 而是先通过hashcode来知道他要放在那一个分段中,然后对这个分段进行加锁, 所以当多线程put的时候,只要不是放在一个分段中,就实现了真正的并行的插入。但是,在统计size的时候,可就是获取hashmap全局信息的时候,就需要获取所有的分段锁才能统计。

分段锁的设计目的是细化锁的粒度,当操作不需要更新整个数组的时候, 就仅仅针对数组中的一项进行加锁操作。

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

这三种锁是指锁的状态,并且是针对Synchronized。在Java 5通过引入锁升级的机制来实现高效Synchronized。这三种锁的状态是通过对象监视器在对象头中的字段来表明的。

  • 偏向锁

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

  • 轻量级锁

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

  • 重量级锁

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

  • 自旋锁

在Java中,自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁, 这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。典型的自旋锁实现的例子,可以参考自旋锁的实现

 

责任编辑:武晓燕 来源: 牧小农
相关推荐

2020-11-09 09:03:35

高并发多线程ThreadLocal

2020-10-13 07:44:45

理解分布式

2018-12-20 09:30:59

分布式高并发多线程

2019-07-15 16:10:00

技术研发指标

2019-06-03 09:13:11

线程进程多线程

2021-07-03 17:44:34

并发高并发原子性

2012-11-27 16:39:06

360手机浏览器

2023-10-12 00:00:00

面试程序多线程

2023-10-18 15:19:56

2012-01-12 10:09:30

Java

2009-04-12 08:50:08

Symbian诺基亚移动OS

2017-11-17 15:57:09

Java多线程并发模型

2018-05-30 16:55:47

阿里Java多线程

2010-03-17 15:45:06

Java多线程求和

2009-10-23 09:26:09

VB.NET多线程

2023-08-09 09:03:49

CPU密集型运算

2017-11-22 09:00:00

2023-10-08 09:34:11

Java编程

2022-05-20 10:20:17

Spring事务MyBatis

2022-05-26 08:31:41

线程Java线程与进程
点赞
收藏

51CTO技术栈公众号