引言
在我上一篇《微服务化之----熔断和隔离》 中,使用责任链模式来进行熔断和限流。其中的并发访问计数器使用的是AtomicInteger,来统计当前服务器的并发数,关键代码如下:
private static AtomicInteger count = new AtomicInteger(0); //并发计数器 //以下三行代码会在多线程中执行 count.getAndIncrement(); //进入方法调用,并发计数器+1 response = getNext().invoke(request); // 自动往下层执行 count.getAndDecrement();//结束方法调用,并发计数器-1
通过调用count.get()即可获取当前服务器的并发数,有朋友就问我可否是用volatile int代替 AtomicInteger,即关键代码变为:
private static volatile int count = 0; //以下三行代码会在多线程中执行 count++; response = getNext().invoke(request); // 自动往下层执行 count--;
答案是不可以的,理由就是count++、count--不是原子性操作。
这个问题实际上就是在多线程环境下并发访问共享数据的问题,引出今天主题-- java并发访问共享数据的三种方式:
1、synchronized 对共享变量进行变更的方法、代码块 使用synchronized关键字(或者Lock)。
2、对共享变量使用volatile关键字。
3、使用Atomic包中的原子性操作类。
在讲述这三种方式之前,先来看看什么是“原子性操作”。
原子性
所谓原子性操作,就是执行的最小单位,不可再分割、执行完毕之前不会任何其他事件所中断。在java中,除long、double型之外的基础类型变量,以及所有的引用型变量的赋值和读取操作都是原子性操作。
由于以前的操作系统是32位,long、 double型在java中是8个字节表示,一共占用64位,因此需要分成两次操作采用完成一个变量的赋值或者读取操作。64位操作系统越来越普及,在64位的HotSpot jvm实现中,对long、 double型做原子性处理(但由于jvm规范没有明确规定,不排除别的jvm实现还是按照32位的方式处理)。
以int为例,int count=0 就是一个原子操作。假设count的当前值是0,另外一个线程设置count=100,这时获取count的值也许还是0。这里就不得不说下java中的主内存与工作内存:
以线程1为例,线程1先从主内存中复制一份 count的拷贝到工作内存,接下来对count重新赋值为100,这时线程2也开始执行 从主内存中获取count的copy到工作内存这时count的值依然还是0。只有当线程1将count的新值同步到主内存完成,其他线程执行才能看到最新的值。这里引出volatile关键字。
对共享变量使用volatile关键字
对采用volatile关键字修饰变量的含义为:告诉jvm该变量直接操作主内存,而不是copy一份拷贝到工作内存,其流程变为:
这时每个线程里看到值都是主内存中的最新值。
但假设把在线程中的赋值操作改为count++(或--),就无法保证每个线程里看到值都是主内存中的最新值了,即便该变量是volatile修饰。
究其原因 对count的直接赋值是原子性操作,但count++操作不是,这个操作相当于两个原子操作:“取值操作”和“赋值操作”。
假设三个线程分别对 count进行+1操作,最终的结果有可能小于3(实际测试时需要调整到一个很大的值才能看到效果,这里的3只是为了说明演示):
原因就是因为count++(--)不是原子性操作,相当于“取值”和“赋值”两个操作。当线程1执行完“取值”操作,但还没有执行“赋值”操作;此时线程2开始执行“取值”操作,但此时count的值其实还是0。然后线程1和线程2分别再执行“赋值”操作,count的值最终变成了1,而不是2。线程3也是同样的道理。所有最终的count结果有可能是1、2、3中的任意一个,出现了不一致性。这就解释了文章开始部分,为什么不能是用volatile int 的原因。
这里写一段测试代码进行测试,为了达到测试效果,测试代码先执行count++操作,再执行count--操作测试代码如下:
public class Test { private volatile static int count = 0; public static void add(){ count++; } public static void sub(){ count--; } public static void main(String[] args) { Test test = new Test(); //启动1000个线程 for (int i=0;i<1000;i++){ new Thread(new Runnable() { @Override public void run() { add(); try { Thread.sleep(1);//为了更好的模拟,睡眠1ms } catch (InterruptedException e) { e.printStackTrace(); } sub(); } }).start(); } //睡眠10秒的作用,等待所有线程执行完毕 try { Thread.sleep(10000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("result:"+count); } }
执行测试方法,期望的测试结果应该是0。但实际结果每次执行都不一样我本机执行三次分别为:3、9、1。说明count++ count--是非原子性的。
总结下:在多线程环境下,为了保证volatile修饰的共享变量的一致性,每个线程中对变量的操作必须是原子性操作(比如单纯的变量赋值:a=1)。一般业务场景为 共享变量表示某个状态,在不同的线程中赋不同的状态值。
那对非原子性的操作,该怎么处理呢,比如这里提到的count++操作。第一种方式是加锁方式:synchronized方法或代码块。
synchronized方法或代码块
synchronized是对一个对象加锁,这里的锁是“排它锁”或者说“独占锁”。简单的讲就是同一时刻只有一个线程在执行count++操作,count字段作为对象的成员变量。synchronized的用法:
1、synchronized使用在静态方法上,会对该类下所有的对象进行加锁。
2、synchronized使用在非静态方法上,会对该类每个对象分别进行加锁。
3、synchronized使用在代码块上,可以对指定对象进行局部加锁。这里又分为静态代码块,和非静态代码块,效果与使用在方法上相同。
需要注意的是采用synchronized加锁方式后,count字段就不必使用volatile修饰了。这里以synchronized使用在静态方法为例,写与上述相同逻辑demo进行测试:
class Test1 { private volatile static int count = 0; public synchronized static void add(){ count++; } public synchronized static void sub(){ count--; } public static void main(String[] args) { Test test = new Test(); //启动1000个线程 for (int i=0;i<1000;i++){ new Thread(new Runnable() { @Override public void run() { add(); try { Thread.sleep(1);//为了更好的模拟,睡眠1ms } catch (InterruptedException e) { e.printStackTrace(); } sub(); } }).start(); } //睡眠3秒的作用,等待所有线程执行完毕 try { Thread.sleep(10000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("result:"+count); } }
多次重复执行mian方法,打印结果均为:0
符合我们的预期,测试通过。
简单总结下synchronized,这种方式其实是通过加锁独占的方式执行一段代码,对共享变量进行一系列的更新操作。但这种方式的代价相对于volatile来说说是非常昂贵的,所以在可以确定共享变量的操作是原子性操作是,建议用volatile,而不要使用synchronized。
除了通过synchronized加锁方式保证共享变量的一致性外,从java1.5开始还提供了Atomic包(java.util.concurrent.atomic),支持一些原子性的操作。
Atomic包中的原子性操作
通过查阅Atomic包中的源码,可以发现它们都是通过调用底层的CAS方法完成原子性的赋值。这里以AtomicInteger的getAndIncrement方法为例进行讲解,类似上述的count++,只是这里是原子性的。源码如下:
public final int getAndIncrement() { return unsafe.getAndAddInt(this, valueOffset, 1); // valueOffset 为当前线程内的对象值 }
可以看到实际上是调用的Unsafe的getAndAddInt方法,它的第二个参数首次传入的是“当前线程内的对象值” 简称期望值,方法内容为:
public final int getAndAddInt(Object var1, long var2, int var4) { // var2为期望值 int var5; do { var5 = this.getIntVolatile(var1, var2);//var5 为实时获取的当前值 } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4)); return var5; }
可以看到这里是一个死循环,这里的getIntVolatile、compareAndSwapInt都是native方法,是非java实现的,通过查阅多方资料,理解其内部主要实现为:通过var5 = this.getIntVolatile(var1, var2)方法,获取该对象的当前值。compareAndSwapInt方法判断期望值var3 是否与当前值var5相等,如果相等就把新的当前值赋值为 var5+ var4(这里var4为1),跳出循环操作完成,否则把期望值var3改为var5,并继续循环获取当前最新的var5,直到var3和var5相等。这种循环判断有些地方称之为“自旋”,或者“自旋锁”,是乐观锁的一种实现方法。
Unsafe类中compareAndSwapxxx系列方法,简称为CAS原子性方法,底层是通过cpu的cas指令完成的原子性操作。java.util.concurrent包中的大量类都是基于Unsafe类的CAS方法实现的,后面有时间在对ReentrantLock等实现方式进行单独总结。
我们以AtomicInteger代替synchronized重新实现上述示例:
class Test2 { private volatile static AtomicInteger count = new AtomicInteger(0); public static void add(){ count.getAndIncrement(); } public static void sub(){ count.getAndDecrement(); } public static void main(String[] args) { Test test = new Test(); //启动1000个线程 for (int i=0;i<1000;i++){ new Thread(new Runnable() { @Override public void run() { add(); try { Thread.sleep(1);//为了更好的模拟,睡眠1ms } catch (InterruptedException e) { e.printStackTrace(); } sub(); } }).start(); } //睡眠3秒的作用,等待所有线程执行完毕 try { Thread.sleep(10000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("result:"+count); } }
多次重复执行mian方法,打印结果均为:0
符合我们的预期,测试通过。
简单总结下:Atomic包中的原子性操作是通过CAS指令不停的自旋循环判断完成的,在高并发的情况下冲突的可能性会增大,这时会不停的循环判断,有一定的性能消耗。但总体来讲性能好过于synchronized的“独占”方式,且性能低于volatile。
最终结论:在多线程环境下,访问共享变量,能用volatile的地方尽量使用volatile;其次考虑使用Atomic包中原子性操作类;最后考虑使用synchronized “独占”的方式。
另外我们也可以不是用synchronized进行加锁,可以使用java1.5中的Lock (如:ReentrantLock)加锁,性能上相对来说也会好一些。只是后续准备单独对java1.5中的Lock单独进行总结,本篇采用synchronized加锁方式进行示例讲解。
以上仅为个人总结观点,如有不当之处,欢迎指正。
相关推荐
《Java并发编程实战》是Java并发编程领域的一本经典著作,它深入浅出地介绍了如何在Java平台上进行高效的多线程编程。这本书的源码提供了丰富的示例,可以帮助读者更好地理解书中的理论知识并将其应用到实际项目中。...
Java并发编程是Java开发中的重要领域,特别是在多核处理器和分布式系统中,高效地利用并发可以极大地提升程序的性能和响应速度。以下是对标题和描述中所提及的几个知识点的详细解释: 1. **线程与并发** - **线程*...
在多线程编程中,线程之间的并发访问可能会导致资源争夺和数据不一致的问题。 Java 提供了多种方式来解决并发问题,包括使用锁、同步代码块和 volatile 变量等。 一、什么是并发问题? 并发问题是指多个线程或进程...
并发编程中的同步机制是防止多个线程同时访问共享资源导致数据不一致的关键。Java提供了多种同步工具,如synchronized关键字、 volatile变量、java.util.concurrent包下的Lock接口(如ReentrantLock)以及Atomic类等...
在Java中,同步是控制多个线程访问共享资源的方式,主要通过`synchronized`关键字和`wait()`, `notify()`, `notifyAll()`方法实现。书中的内容可能会涵盖如何使用这些机制来确保数据的一致性和完整性。 锁机制是...
- **锁机制**:Java中的锁机制主要包括synchronized关键字、ReentrantLock等,这些锁机制用于控制对共享资源的访问,防止多线程环境下数据的不一致性问题。 - **原子操作**:介绍Java中的原子类如AtomicInteger、...
#### 三、保护共享数据 在多线程环境中,保护共享数据至关重要,以确保数据的一致性和完整性。以下是一些常用的技术和策略: 1. **Volatile**: `volatile`关键字可以用来确保变量的写入立即对其他线程可见,但不...
《Java并发编程实战》是一本深入探讨Java平台并发编程的权威指南。这本书旨在帮助开发者理解和掌握在Java环境中创建高效、可扩展且可靠的多线程应用程序的关键技术和实践。它涵盖了从基本概念到高级主题的广泛内容,...
2. **同步机制**:Java并发编程的核心在于同步,以防止数据不一致性和资源竞争。`synchronized`关键字用于实现临界区的互斥访问,确保同一时刻只有一个线程执行特定代码块。此外,还有`wait()`, `notify()`, `...
这些机制用于控制对共享资源的访问,防止数据不一致和竞态条件。 3. **并发容器**:书中详细讨论了`java.util.concurrent`包下的并发容器,如`ConcurrentHashMap`、`CopyOnWriteArrayList`和`BlockingQueue`等。...
- 使用锁来控制对共享数据的访问是保证线程安全的一种常见方法。Java提供了多种类型的锁,如`synchronized`、`ReentrantLock`、`ReadWriteLock`等,它们可以实现不同的并发策略。 - 除了互斥锁,还有读写锁(`...
《Java并发编程实践》是一本深入探讨Java多线程编程的经典著作,由Brian Goetz、Tim Peierls、Joshua Bloch、Joseph Bowles和David Holmes等专家共同编写。这本书全面介绍了Java平台上的并发编程技术,是Java开发...
《JAVA并发编程艺术》是Java开发者深入理解和掌握并发编程的一本重要著作,它涵盖了Java并发领域的核心概念和技术。这本书详细阐述了如何在多线程环境下有效地编写高效、可靠的代码,对于提升Java程序员的技能水平...
3. 线程安全问题:在多线程环境中,多个线程可能同时访问和修改共享资源,导致数据不一致或竞态条件等问题。因此,合理使用同步机制(如锁、信号量、原子变量等)是必须的。 4. 线程间的通信与协作机制,包括生产者-...
#### 三、Java并发工具类 ##### 3.1 原子类 Java并发工具包中提供了原子类,如AtomicInteger、AtomicLong等,它们可以实现对整型或长整型变量的原子操作,无需显式加锁即可保证线程安全。 ##### 3.2 阻塞队列 阻塞...
3. **锁**:Java并发库中的`java.util.concurrent.locks`包提供了更高级的锁机制,如可重入锁(`ReentrantLock`)、读写锁(`ReadWriteLock`)和条件变量(`Condition`),这些工具允许更灵活的控制并发访问。 4. **并发...
本篇文章将深入探讨Java并发编程的相关知识点,主要基于提供的两个文件——"Java并发编程实战(中文版).pdf"和"Java Concurrency in Practice.pdf"。 1. **线程与并发** - **线程基础**:Java中的线程是并发执行...
本教程将深入探讨Java并发编程的核心概念、最佳实践以及常见陷阱。 首先,我们要了解Java中的线程。线程是操作系统分配CPU时间的基本单元,Java通过Thread类来抽象线程。创建线程有两种方式:继承Thread类并重写run...
Java并发集合,如ConcurrentHashMap、CopyOnWriteArrayList等,设计时考虑了并发性能,能够在不加锁的情况下提供线程安全的访问。这些集合内部实现了复杂的同步策略,提高了并发效率。 并发工具类,如...