万字图解工作面试必备,Java线程安全问题和解决方案

开发 前端
Loop unswitching 是一种编译器优化技术。通过复制循环主体,在 if 和 else 语句中放一份循环体代码,实现将条件句的内部循环移到循环外部,进而提高循环的并行性。由于处理器可以快速运算矢量,因此执行速度得到提升。

前言

上一篇介绍了《​​Java多线程的作用​​》,使用场景和创建方式等基础,本篇主要介绍:

  • 多线程的安全问题
  • 从指令集层面分析线程安全问题产生原因
  • 多线程安全问题解决方案
  • 锁分类
  • synchronized和Lock的底层实现原理

文章涵盖广而全,对工作和面试都有很大帮助,值得收藏认真阅读,不错的话记得点赞,关注支持哦!

线程运行机制

一旦调用start方法,线程处于runnable状态【可运行状态】。也就是可能正在运行也可能没有运行,这取决于操作系统给线程提供运行的时间。(Java的规范说明没有将它作为一个单独状态。一个正在运行中的线程仍然处于可运行状态。)

一旦一个线程开始运行,它不必始终保持运行。运行中的线程被中断,目的是为了让其他线程获得运行机会。线程调度的细节依赖于操作系统提供的服务。抢占式调度系统给每一个可运行线程一个时间片来执行任务。当时间片用完,操作系统剥夺该线程的运行权,并给另一个线程运行机会。当选择下一个线程时,操作系统考虑线程的优先级。

现在所有的桌面以及服务器操作系统都是用抢占式调度。但是,像手机这样的小型设备可能使用协作式调度,在这样的设备中,一个线程只有调用yield方法,或者被阻塞或等待时,线程才失去控制权。

在具有多个处理器的机器上,每一个处理器运行一个线程,可以有多个线程并行运行。当然,如果线程的数目多于处理器的数目,调度器依然采用时间片机制。

记住,在任何给定时刻,一个可运行的线程可能正在运行也可能没有运行(这就是为什么将这个状态称为可运行而不是运行)

Java的多线程可以充分利用CPU资源提高计算速度和处理后台任务等,而且线程之间的运行机制是抢占式,或者说是随机的,这就会导致多个线程对共享数据操作时可能出现错误的结果,对于多线程并发时会使程序出现bug的代码称作线程不安全的代码,这就是线程安全问题

存在线程安全问题程序

比如公司研发了一款手机,提供两个售货渠道卖10部手机,每一个线程就是一个售货渠道,当然最多只能卖出10部不能超卖

public class GoodsMain {
// 10部手机
private static int stocks = 10;
public static void main(String[] args) {
// 开启线程购买
new Thread(() -> {
// 判断,如果大于0就还可以继续售卖
while(stocks > 0) {
// 输出那个线程卖出第几部手机,并 -1
System.out.println(Thread.currentThread().getName() + "卖出第" + stocks--);
}
},"售卖渠道1").start();
new Thread(() -> {
while(stocks > 0) {
System.out.println(Thread.currentThread().getName() + "卖出第" + stocks--);
}
},"售卖渠道2").start();
}
}

打印结果:

运行结果发现出现了0号手机,卖出了11部,明显是有问题的。你也可以试着运行,每次的运行结果不一样,而且出现这种BUG也是随机的,你可能运行十几二十次都不会出现这问题

问题分析

宏观分析

要明确一个前提是只有得到CPU的时间片线程才会被执行,而且CPU不保障一次将线程执行完,也就是说,CPU会在线程之间切换执行,上述例子出现超卖的原因也在这里

  • 当还有最后一部手机时,线程2经过while循环判断进入循环体,这时还没有对手机进行售卖,线程2的时间片用完,线程挂起,CPU开始执行线程1
  • 线程1这时判断while循环条件,仍然成立,进入线程体,输出【售卖渠道1卖出第1部手机】,这时切换到线程2
  • 上次线程2已经通过了while循环的判断,所以继续执行,不会再次进行判断,但是stocks值已经被线程1减去变成0,这时再输出就是【售卖渠道2卖出第0部手机】
  • 所以如果是三个线程在卖手机,也有可能出现【卖出第-1部手机】的情况,你理解吗?

微观指令集层面分析

上边我们在操作共享变量stocks时使用了stocks--这样的语法,自减操作也有大学问

stocks--:会对变量进行-1操作,--在变量之后,所以是后--,意思是如果变量参与了运算,则先完成运算再进行-1操作,比如上述例子与字符串进行相加运算,所以stocks变量会先于字符串完成拼接输出数据之后再对变量进行 -1 操作

而且程序运行时需要交给CPU执行,系统在执行运算时会将代码转换为指令集进行运算,在指令集方面,stocks--这样的一个自减操作会被分成三个指令操作:

  • 将内存中的变量值加载到寄存器,我们暂且将这个操作表记作iload
  • 在寄存器中执行自增操作,将这个操作记为isub
  • 将寄存器的值保存到内存中,将这个操作记为istore
  • 当然while判断也是将值从内存中加载出来做判断

情况1:线程之间指令集无交叉,运行结果与预期相同,线程1从内存加载值,运算之后再将值存进内存,线程2获取值,发现值为0,while判断不成立

情况2:线程之间指令集存在交叉,结果可能存在问题,指令交叉计算后得知没有及时刷新进内存,导致另外的线程获取到的是旧值,就会存在少减情况

情况3:指令完全交叉,现象与情况2一样,出现库存少减现象

根据上边的几种情况分析,发现线程运行时没有出现指令交叉结果是预期的,如果出现指令交叉就会存在库存少减现象,是因为自减操作不是原子的是可以再分割的,线程之间独立,线程内计算的值并没有直接刷新进内存,导致别的线程并不会得到最新的数据,多线程并发执行时很可能出现指令交叉,导致线程安全问题,出现错误结果。

解决上述线程不安全问题,我们常用的方法就是加锁

什么是加锁

在Java多线程中,当两个或以上线程对同一数据进行操作时,就会产生【竞争条件】的现象,这种现象产生的根本原因是因为多个线程在对同一个数据进行操作,此时对该数据的操作是非“原子化”的,可能前一个线程对数据的操作还没有结束,后一个线程又开始对同样的数据开始进行操作,这就可能会造成数据结果的变化未知。

为了解决由于【抢占式执行】导致的线程安全问题,我们可以对共享数据进行加锁,可以理解为给多线程操作的共享数据设置一个操作权限,谁拿到这个锁,谁就有权利操作共享数据,当一个线程拿到共享数据的锁后,就会把共享数据锁起来,其他线程如果也要操作这个共享数据,需要等待已经获取到锁的线程执行完之后释放锁,其他拿个线程得到这个锁,谁就可以操作共享数据。

举个例子:有一家饭店的包间非常不错,很多人都想在包间中就餐。当包间被顾客预定之后就相当于被上了锁,其他顾客必须等待上一个顾客享用完服务之后才可再预定使用,预定到的就会再次对包间上锁,其他顾客无法享用这个包间。这样就不会乱糟糟的了是吧,不然就跟没有秩序一样,谁都可以进包间里边就会发生冲突。这里的顾客就是一个一个的线程,这里的包间就是共享数据,预定包间成功就相当于加的锁

当你使用完之后,释放锁,其他线程竞争锁,当一个线程抢到锁之后,就会进入套房享用服务

当然,如果世界上只有一个客户,也就是只有一个线程就不需要加锁了,对吧!

如何加锁

Java中最常见的是使用 synchronized 加锁。synchronized 是互斥锁,有互斥效果,即同一时刻只能有一个线程操作共享数据,某个线程执行到 synchronized 中时, 其他线程如果也执行这块代码,就会阻塞等待。线程进入 synchronized 修饰的代码块, 相当于 加锁,退出 synchronized 修饰的代码块, 相当于 解锁

加锁也可以称为线程同步,同步也好理解,就是一个一个来嘛

方式1:使用synchronized关键字修饰方法,这样会使方法所在的对象加上一把锁

实现类:

public class Goods implements Runnable{
private static int stocks = 10;

@Override
public void run() {
// 调用卖手机方法
sellMobile();
}
// 共享方法
public synchronized void sellMobile() {
while(stocks > 0) {
System.out.println(Thread.currentThread().getName() + "卖出第" + stocks-- + "部手机");
}
}
}

测试类:

public class GoodsMain {

public static void main(String[] args) {
// 1、创建Runnable实现类对象
Goods goods = new Goods();

// 开启线程购买
Thread t1 = new Thread(goods, "售卖窗口1");
Thread t2 = new Thread(goods, "售卖窗口2");

t1.start();
t2.start();
}
}

上边的代码可以解决线程安全问题,但是因为while条件中直接判断的共享资源,所以将while直接锁进嘞小房间,所以所有的手机都会被同一个线程售出,比如:线程1获取到锁资源后上锁,进入while循环,沉迷其中不可自拔,一口气消费完才释放锁。我们可以通过以下代码优化,实现线程交替运行:

定义 flag 变量标记是否还有库存,while循环判断库存标记,这样可以保障当线程1判断while之后挂起还没有调用售卖方法时仍然可能丢失CPU执行权,切换到其他线程执行。

public class Goods implements Runnable{
private static int stocks = 10;
// 是否卖完
private static boolean flag = true;
@Override
public void run() {
// 循环调用
while(flag) {
// 调用售卖方法,在调用该方法时可能出现线程切换
sellMobile();
}
}
// 同步方法,进入到该方法中就需要等待该线程执行完所有的操作还可能切换线程
public synchronized void sellMobile() {
// 判断是否卖完
if(stocks > 0) {
System.out.println(Thread.currentThread().getName() + "卖出第" + stocks-- + "部手机");
}else {
flag = false;
}
}
}

运行结果:在运行结果截图中,发现写了一个sleep方法,这是为了让线程进入超时等待可以释放CPU执行权,来达到切换线程的目的,实际开发中是不会使用sleep方法的,所以上边贴出的代码中并没有sleep方法调用

为可看出效果,也可以将库存调为10万台,有充分的资源支撑线程切换,可以看出下图同样线程1和线程2之间切换,并且没有出现超卖现象

方式2:使用synchronized关键字对代码段进行加锁,但是需要显式指定加锁的对象。

public class Goods implements Runnable{
private static int stocks = 10;
private static boolean flag = true;
@Override
public void run() {
while(flag) {
// 加锁
synchronized (this) {
if(stocks > 0) {
System.out.println(Thread.currentThread().getName() + "卖出第" + stocks-- + "部手机");
}else {
flag = false;
}
}
}
}
}

运行结果:

方式3:使用synchronized关键字修饰静态方法,相当于对当前类的类对象进行加锁

public class GoodsMain {

private static int stocks = 10;
private static boolean flag = true;

public static void main(String[] args) {
// 开启线程1
new Thread(() -> {
while (flag) {
sellMpbile();
}
},"售卖窗口1").start();
// 开启线程2
new Thread(() -> {
while (flag) {
sellMpbile();
}
},"售卖窗口2").start();
}
// 静态方法,售卖手机
public synchronized static void sellMpbile() {
if(stocks > 0) {
System.out.println(Thread.currentThread().getName() + "卖出第" + stocks-- + "部手机");
}else {
flag = false;
}
}
}

常见的用法差不多就是这些,对于线程加锁(线程拿锁),如果两个线程同时拿一个对象的锁,就会产生锁竞争,两个线程同时拿两个不同对象的锁不会产生锁竞争。 对于synchronized这个关键字,它的英文意思是同步,但是同步在计算机中是存在多种意思的,比如在多线程中,这里同步的意思是“互斥”;而在IO或网络编程中同步则是另一个意思

三种方式锁对象区别:

  • 同步方法:锁对象是this
  • 同步代码块:锁对象可以是任意对象,例子中使用的是this
  • 静态同步代码块:锁对象是当前类的class对象即,类.class

synchronized 的工作过程:

  1. 获得互斥锁lock
  2. 从主内存拷贝变量的最新副本到工作的内存
  3. 执行代码
  4. 将更改后的共享变量的值刷新到主内存
  5. 释放互斥锁unlock

综上,synchronized关键字加锁有如下性质:互斥性,刷新内存性,可重入性。

所谓可重入,即一个线程已经获得了某个锁,当这个线程要再次获取这个锁时,依然可以获取成功,不会发生死锁的情况。synchronized就是一个可重入锁。

可重入的条件

  • 不在函数内使用静态或全局数据。
  • 不返回静态或全局数据,所有数据都由函数的调用者提供。
  • 使用本地数据(工作内存),或者通过制作全局数据的本地拷贝来保护全局数据。
  • 不调用不可重入函数

可重入与线程安全

一般而言,可重入的函数一定是线程安全的,反之则不一定成立。在不加锁的前提下,如果一个函数用到了全局或静态变量,那么它不是线程安全的,也不是可重入的。如果我们加以改进,对全局变量的访问加锁,此时它是线程安全的但不是可重入的,因为通常的加锁方式是针对不同线程的访问(如Java的synchronized),当同一个线程多次访问就会出现问题。只有当函数满足可重入的四条条件时,才是可重入的

synchronized是可重入锁

从设计上讲,当一个线程请求一个由其他线程持有的对象锁时,该线程会阻塞。当线程请求自己持有的对象锁时,如果该线程是重入锁,请求就会成功,否则阻塞。

我们回来看synchronized,synchronized拥有强制原子性的内部锁机制,是一个可重入锁。因此,在一个线程使用synchronized方法时调用该对象另一个synchronized方法,即一个线程得到一个对象锁后再次请求该对象锁,是永远可以拿到锁的。

在Java内部,同一个线程调用自己类中其他synchronized方法/块时不会阻碍该线程的执行,同一个线程对同一个对象锁是可重入的,同一个线程可以获取同一把锁多次,也就是可以多次重入。原因是Java中线程获得对象锁的操作是以线程为单位的,而不是以调用为单位的。

synchronized可重入锁的实现

每个锁关联一个线程持有者和一个计数器。当计数器为0时表示该锁没有被任何线程持有,那么任何线程都都可能获得该锁而调用相应方法。当一个线程请求成功后,JVM会记下持有锁的线程,并将计数器计为1。此时其他线程请求该锁,则必须等待。而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增。当线程退出一个synchronized方法/块时,计数器会递减,如果计数器为0则释放该锁。

加锁后分析

当下成1获取到锁对象之后就会将共享资源锁起来【lock】,当线程1处理完之后释放锁【unlock】,其他线程来竞争这把锁,谁得到锁谁就将资源锁住【lock】,依次释放和获得锁,没有获取到锁的线程就会进入阻塞状态

加锁后线程就是串行执行,与单线程其实没有很大的区别,那多线程是不是没有用了呢?但是对方法加锁后,线程运行该方法才会加锁,运行完该方法就会自动解锁,况且大部分操作并发执行是不会造成线程安全的,只有少部分的修改操作才会有可能导致线程安全问题,因此整体上多线程运行效率还是比单线程高得多。

ReentrantLock可重入锁

从JDK5.0开始,Java提供了更强大的线程同步机制——通过显示定义同步锁对象来实现同步。同步锁使用Lock对象充当。该锁对象在Java的JUC包中

java.util.concurrent.locks.Lock接口是控制多个线程对共享资源进行访问的工具。锁提供了对共享资源的独占访问。每次只能有一个线程对Lock对象加锁,线程开始访问共享资源之前应先获得Lock对象

ReentrantLock类实现了Lock,ta拥有与synchronized 相同的并发性和内存语义,在实现线程安全的控制中,比较常用的是ReentrantLock,从名字上可以看出该所对象是可重入锁,可以显式加锁,释放锁

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Goods implements Runnable {
private int stocks = 1000;
private boolean flag = true;
// 创建可重入锁对象
private final Lock lock = new ReentrantLock();

@Override
public void run() {
while (flag) {
try {
// 加锁
lock.lock();
if (stocks > 0) {
System.out.println(Thread.currentThread().getName() + "卖出第" + stocks-- + "部手机");
} else {
flag = false;
}
} catch (Exception e) {
System.out.println("发生异常:" + e);
} finally {
// 在finally中 解锁,避免线程意外终止没有解锁造成死锁
lock.unlock();
}
}
}
}

使用 ReentrantLock 的时候,建议把 Lock 和 方法体 放在 try{} 代码块中,然后释放锁 unlock() 放在 finally{} 代码块中保证锁释放成功~,如果线程发生异常意外终止,锁没有释放成功,别的线程也获取不到锁,就会出现死锁,也就是谁都拿不到锁,谁都运行不了程序

synchronized和Lock加锁区别

  • lock是一个接口,而synchronized是java的一个关键字
  • synchronized是隐式的加锁和解锁,以获取锁的线程执行完同步代码,释放锁,或线程执行发生异常,jvm会让线程释放锁,lock是显示的加锁和解锁,在finally中必须释放锁,不然容易造成线程死锁
  • synchronized可以作用在方法和代码块上,而lock只能作用在代码块上
  • synchronized是阻塞式加锁,假设A线程获得锁,B线程等待。如果A线程阻塞,B线程会一直等待,而lock中获取锁分情况而定,Lock有多个锁获取的方式,可以通过trylock方法尝试获取锁,线程可以不用一直等待,支持非阻塞式加锁
  • synchronized锁状态无法判断,Lock锁状态可以判断
  • synchronized 是可重入 不可中断 非公平锁,Lock有多种实现,可以是可重入 可判断 可公平锁(两者皆可

Lock锁

以下是Lock接口的源码,简单翻译如下:

public interface Lock {

/**
* 获取锁,如果锁被暂用则一直等待
*/
void lock();

/**
* 用该锁的获得方式,如果线程在获取锁的阶段进入了等待,那么可以中断此线程,先去做别的事
*/
void lockInterruptibly() throws InterruptedException;

/**
* 注意返回类型是boolean,如果获取锁的时候锁被占用就返回false,否则返回true
*/
boolean tryLock();

/**
* 比起tryLock()就是给了一个时间期限,保证等待参数时间
*/
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;

/**
* 释放锁
*/
void unlock();

}

lock方法:

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class LockTest {
// 创建可重入锁对象
private final Lock lock = new ReentrantLock();

// 需要同步方法
private void method(){
// 加锁
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + "获得了锁");
}catch(Exception e){
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName() + "释放了锁");
// 解锁
lock.unlock();
}
}

public static void main(String[] args) {
LockTest lockTest = new LockTest();
// 1、创建线程1
Thread t1 = new Thread(() -> {
lockTest.method();
},"线程1");
// 2、创建线程2
Thread t2 = new Thread(() -> {
lockTest.method();
},"线程2");
// 3、启动线程
t1.start();
t2.start();
}
}

运行结果:

tryLock方法:

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class LockTest {
private final Lock lock = new ReentrantLock();

// 需要同步方法
private void method(){
// 尝试获取锁,并加锁
if(lock.tryLock()){
try {
System.out.println(Thread.currentThread().getName() + "获得了锁");
}catch(Exception e){
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName() + "释放了锁");
// 解锁
lock.unlock();
}
}else{
System.out.println("我是【"+Thread.currentThread().getName()+"】有人占着锁,我就不要啦");
}
}

public static void main(String[] args) {
LockTest lockTest = new LockTest();
Thread t1 = new Thread(() -> {
lockTest.method();
},"线程1");

Thread t2 = new Thread(() -> {
lockTest.method();
},"线程2");

t1.start();
t2.start();
}
}

tryLock就是尝试获取锁,如果所被别的线程获取,则直接放弃获取,不阻塞,好比追一个小姐姐,人家有对象了,直接放弃,而lock则是等着【分手接盘】

而tryLock(long time, TimeUnit unit),则是锁被别的线程拿到,会等待指定时间,如果还没获取到就放弃,好比给小姐姐一段时间分手,如果没分就拉到,分了就接盘

运行结果:

ReentrantLock源码分析

下方代码是从JDK源码中摘录出来的,对部分代码做了注释,可以细品一下

ReentrantLock在创建对象时可以选择是否为公平锁,默认为非公平锁。面试时Java中的锁分类也是高频问点!在下边也为大家介绍到:

/**
ReentrantLock无参构造,相当于使用 ReentrantLock(false),默认创建非公平锁
*/
public ReentrantLock() {
sync = new NonfairSync();
}

/**
根据给定的公平策略创建锁实例,
1、true创建公平锁,false创建非公平锁
*/
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}


/**
非公平锁
*/
static final class NonfairSync extends Sync {
private static final long serialVersionUID = 7316153563782823691L;

/**
Performs lock. Try immediate barge, backing up to normal
acquire on failure.
*/
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}

protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
}

/**
公平锁
*/
static final class FairSync extends Sync {
private static final long serialVersionUID = -3000897897090466540L;

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

/**
* Fair version of tryAcquire. Don't grant access unless
* recursive call or no waiters or is first.
*/
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
}

锁分类

公平锁/非公平锁

  • 公平锁是指多个线程按照申请锁的顺序来获取锁。
  • 非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。有可能,会造成优先级反转或者饥饿现象。
  • 对于ReentrantLock而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。
  • 对于synchronized而言,也是一种非公平锁。由于其并不像ReentrantLock是通过AQS的来实现线程调度,所以并没有任何办法使其变成公平锁

可重入锁

  • 可重入锁又名递归锁,是指同一个线程如果获取到锁对象,在线程内的其他代码块中部会自动获取锁。
  • 对于Java ReentrantLock而言, 他的名字就可以看出是一个可重入锁,其名字是ReentrantLock重新进入锁。
  • 对于synchronized而言,也是一个可重入锁。可重入锁的一个好处是可一定程度避免死锁

private void method(){
// 获取this对象锁
synchronized (this) {
// 再次获取统一对象的锁,仍然可以
synchronized (this) {

}
}
}

独享锁/共享锁

  • 独享锁是指该锁一次只能被一个线程所持有。
  • 共享锁是指该锁可被多个线程所持有。
  • 对于Java ReentrantLock而言,其是独享锁。但是对于Lock的另一个实现类ReadWriteLock,其读锁是共享锁,其写锁是独享锁。
  • 读锁的共享锁可保证并发读是非常高效的,读写,写读 ,写写的过程是互斥的。
  • 独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。
  • 对于synchronized而言,当然是独享锁

互斥锁/读写锁

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

乐观锁/悲观锁

  • 乐观锁与悲观锁不是指具体的什么类型的锁,而是指看待并发同步的角度。
  • 悲观锁认为对于同一个数据的并发操作,一定是会发生修改的,哪怕没有修改,也会认为修改。因此对于同一个数据的并发操作,悲观锁采取加锁的形式。悲观的认为,不加锁的并发操作一定会出问题。
  • 乐观锁则认为对于同一个数据的并发操作,是不会发生修改的。在更新数据的时候,会采用尝试更新,不断重新的方式更新数据。乐观的认为,不加锁的并发操作是没有事情的。
  • 从上面的描述我们可以看出,悲观锁适合写操作非常多的场景,乐观锁适合读操作非常多的场景,不加锁会带来大量的性能提升。
  • 悲观锁在Java中的使用,就是利用各种锁。
  • 乐观锁在Java中的使用,是无锁编程,常常采用的是CAS算法,典型的例子就是原子类,通过CAS自旋实现原子操作的更新

分段锁

  • 分段锁其实是一种锁的设计,并不是具体的一种锁,对于ConcurrentHashMap而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作。
  • 我们以ConcurrentHashMap来说一下分段锁的含义以及设计思想,ConcurrentHashMap中的分段锁称为Segment,它即类似于HashMap(JDK7与JDK8中HashMap的实现)的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表;同时又是一个ReentrantLock(Segment继承了ReentrantLock)。
  • 当需要put元素的时候,并不是对整个hashmap进行加锁,而是先通过hashcode来知道他要放在那一个分段中,然后对这个分段进行加锁,所以当多线程put的时候,只要不是放在一个分段中,就实现了真正的并行的插入。
  • 但是,在统计size的时候,可就是获取hashmap全局信息的时候,就需要获取所有的分段锁才能统计。
  • 分段锁的设计目的是细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作

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

  • 这三种锁是指锁的状态,并且是针对synchronized。在Java 5通过引入锁升级的机制来实现高效synchronized。这三种锁的状态是通过对象监视器在对象头中的字段来表明的。
  • 偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。
  • 轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。
  • 重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低

自旋锁

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

两种锁的底层实现方式

synchronized:Java是用字节码指令来控制程序(这里不包括热点代码编译成机器码)。在字节指令中,存在有synchronized所包含的代码块,那么会形成2段流程的执行

如下代码:

public class LockTest {

public void method(){
// 获取this对象锁
synchronized (this) {
System.out.println(Thread.currentThread().getName());
}
}
}

通过 javap -c LockTest.class 指令获取该类的class字节码数据如下:

如上就是这段代码段字节码指令,我们可以清晰段看到,其实synchronized映射成字节码指令就是增加来两个指令:monitorenter和monitorexit。当一条线程进行执行的遇到monitorenter指令的时候,它会去尝试获得锁,如果获得锁那么锁计数+1(因为它是一个可重入锁,所以需要用这个锁计数判断锁的情况),如果没有获得锁,那么阻塞。当它遇到monitorexit的时候,锁计数器-1,当计数器为0,那么就释放锁。

有的朋友看到这里就疑惑了,为什么有2个monitorexit呀?马上回答这个问题:synchronized锁释放有两种机制,一种就是执行完释放;另外一种就是发送异常,虚拟机释放。图中第二个monitorexit就是发生异常时执行的流程。而且,从图中我们也可以看到在第18行,有一个goto指令,也就是说如果正常运行结束会跳转到26行执行。

Lock:Lock实现和synchronized不一样,后者是一种悲观锁,它胆子很小,它很怕有人和它抢吃的,所以它每次吃东西前都把自己关起来。而Lock呢底层其实是CAS乐观锁的体现,它无所谓,别人抢了它吃的,它重新去拿吃的就好啦,所以它很乐观。具体底层怎么实现,如果面试问起,你就说底层主要靠volatile和CAS操作实现的。

尽可能去使用synchronized而不要去使用LOCK,jdk1.6~jdk1.7中对 synchronized 进行优化:

1、线程自旋和适应性自旋

Java线程其实是映射在内核之上,线程的挂起和恢复会极大的影响开销。并且jdk官方人员发现,很多线程在等待锁的时候,在很短的一段时间就获得了锁,所以它们在线程等待的时候,并不需要把线程挂起,而是让他无目的的循环,一般设置10次。这样就避免了线程切换的开销,极大的提升了性能。 而适应性自旋,是赋予了自旋一种学习能力,它并不固定自旋10次一下。它可以根据它前面线程的自旋情况,从而调整它的自旋,甚至是不经过自旋而直接挂起

2、锁消除【Lock Elimination】

锁消除就是把不必要的同步在编译阶段进行移除,惊讶!我自己写的代码我会不知道这里要不要加锁?需要你教我做事?我加了锁就是表示这边会有同步呀? 并不是这样,这里所说的锁消除并不一定指代是你写的代码的锁消除,而是根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁,我打一个比方: 在jdk1.5以前,我们的String字符串拼接操作其实底层是StringBuffer实现,而在jdk1.5之后,是用StringBuilder来拼接。我们考虑前面的情况,比如如下代码:

String str1="qwe";
String str2="asd";
String str3=str1+str2;

底层实现会变成这样:

StringBuffer sb = new StringBuffer();
sb.append("qwe");
sb.append("asd");

StringBuffer是一个线程安全的类,也就是说两个append方法都会同步,通过指针逃逸分析(就是变量不会外泄),我们发现在这段代码并不存在线程安全问题,这个时候就会把这个同步锁消除

3、锁粗化

在用synchronized的时候,我们都讲究为了避免大开销,尽量同步代码块要小。Hotspot 确实进行了锁粗化优化,可以有效合并几个相邻同步块,从而降低锁开销。能够把下面的代码

synchronized (obj) {  
// 语句 1
}
synchronized (obj) {
// 语句 2
}

转换为:

synchronized (obj) {  
// 语句 1
// 语句 2
}

Hotspot 能否对循环进行这种优化?例如,把

for (...) {  
synchronized (obj) {
// 一些操作
}
}

转换为

synchronized (this) {  
for (...) {
// 一些操作
}
}

理论上,没有什么能阻止我们这样做,甚至可以把这种优化看作只针对锁的优化,像 loop unswitching 一样。然而,缺点是可能把锁优化后变得过粗,线程在执行循环时会占据所有的锁

小贴士:Loop unswitching 是一种编译器优化技术。通过复制循环主体,在 if 和 else 语句中放一份循环体代码,实现将条件句的内部循环移到循环外部,进而提高循环的并行性。由于处理器可以快速运算矢量,因此执行速度得到提升。

4、轻量级锁和偏向锁

轻量级锁和偏向锁在上边锁分类中已经解释,不再复述,JDK将 synchronized 升级成了这两种特性的锁

总结

这里对Java多线程的运行机制,线程安全问题产生的原因和解决方案,锁分类,并对 synchronized 和 Lock的底层原理进行分析。多线程是一门比较深的学问,不同的场景使用方法都不同,但是本质几乎一样,如果您对本文有什么疑问或者问题欢迎在评论区指出。

Java的多线程仅仅是开始远没有结束,比如多线程的8锁问题,JUC中的原子类,volatile关键字,ThreadLocal,分布式锁,线程通信,JDK中各个线程安全类如何实现线程安全的等等都会陆续更新出来,欢迎持续关注!

文章出自:​石添的编程哲学​,如有转载本文请联系【石添的编程哲学】今日头条号。

责任编辑:武晓燕 来源: 今日头条
相关推荐

2023-03-24 15:06:03

2022-04-07 07:40:40

线程安全变量

2020-09-21 10:50:24

Java多线程代码

2020-03-11 09:57:10

数据安全网络安全网络攻击

2019-04-02 08:20:37

2023-10-16 16:08:42

工业 4.0物联网边缘计算

2022-03-31 10:25:20

物联网工业 4.0大数据分析

2019-06-14 05:00:05

2009-10-28 11:27:49

linux服务器安全

2020-06-29 15:03:34

远程工作网络安全网络攻击

2018-07-13 11:30:01

2020-12-31 15:31:18

AI 数据人工智能

2009-07-13 10:36:18

2009-11-12 15:05:13

USB移动安全解决方案

2024-04-01 08:34:23

2019-03-07 09:00:00

文本挖掘机器学习人工智能

2023-10-16 11:23:03

2010-08-23 16:13:11

DHCP服务器

2024-09-17 17:50:28

线程线程安全代码

2023-04-12 11:32:33

网络
点赞
收藏

51CTO技术栈公众号