Java 并发基础学习
Posted 一口仨馍
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java 并发基础学习相关的知识,希望对你有一定的参考价值。
本文已授权微信公众号《鸿洋》原创首发,转载请务必注明出处。
三个基础概念
- 原子性。一个操作或者一系列骚操作,要么全部执行要么全部不执行。数据库中的“事物”就是个典型的院子操作。
- 可见性。当一个线程修改了共享属性的值,其它线程能立刻看到共享属性值的更改。举个例子:由于JMM(Java Memory Model)分为主存和工作内存,共享属性的修改过程为从主存中读取并复制到工作内存中,在工作内存中修改完成之后,再刷新主存中的值。如果线程A在工作内存中修改完成但还没有刷新主存中的值,线程B看到的值还是旧值。这样可见性就没法保证。
- 有序性。程序的运行顺序似乎和我们编写逻辑的顺序是一致的,但计算机在实际执行中却并不一定。为了提高性能,编译器和处理器都会对代码进行重新排序。但是有个前提,重新排序的结果要和单线程执行程序顺序一致。
int a = 0; // 语句A
int b = 1; // 语句B
int c = a + b; // 语句C
由于语句A和语句B没有数据依赖。重排序后,语句A和语句B,在计算机中的执行顺序可能是AB也可能是BA,但AB都与C有数据依赖,所以AB都在C前面执行。
Java中控制并发的几种方式
- volatile
- synchronized
- CAS/AQS
- concurrent并发包
volatile
volatile用来保证可见性和有序性,不保证原子性。
volatile保证的可见性
volatile修饰的属性保证每次读取都能读到最新的值,可是并不会更新已经读了的值,它也无法更新已经读了的值。线程A在工作内存中修改共享属性值会立即刷新到主存,线程B/C/D每次通过读写栅栏来达到类似于直接从主存中读取属性值,注意,是类似,网上有些说volatile修饰的变量读写直接在主存中操作,这种说法是不对的,只是表现出类似的行为。读写栅栏是一条CPU指令,插入一个读写栅栏, 相当于告诉CPU和编译器先于这个命令的必须先执行,后于这个命令的必须后执行(有序性)。读写栅栏另一个作用是强制更新一次不同CPU的缓存。例如,一个写栅栏会 把这个栅栏前写入的数据刷新到缓存,以此保证可见性。
valatile保证的有序性
当对volatile修饰的属性进行读/写操作时,其前面的代码必须已经执行完成且结果对后续的操作可见。在重排序时,以volatile修饰属性的读/写操作代码行为分界线,读/写操作前面的代码不许排序到后面,后面同理不许排序到前面。由此保证有序性.
// 线程A
bean = new Bean(); // 语句A
inited = true; // 语句B
// 线程B
if(inited) // 语句C
bean.getAge(); // 语句D
在线程A中语句AB没有任何数据依赖,所以可能会被重排序成先执行语句B,后执行语句A。假设线程A先执行完语句B之后(这时还没有执行语句A)被挂起,CPU转而执行线程B,由于bean对象没有初始化,所以在执行到语句D就会出错。如果inited属性用volatile修饰,就不会发生这种错误的重排序。
volatile不保证原子性
由于volatile保证可见性和有序性,被volatile修饰的共享属性一般并发读/写没有问题,可以看做是一种轻量级的synchronized的实现。但有些情况比较特殊,比如i++自增。举个栗子。
volatile int a = 0; // 语句A
a++; // 语句B
a++。其实包含了两步操作。读取a, 执行a+1并将a+1结果赋值给a。假设线程A执行完第一步之后被挂起。线程B执行了a++。那么主存中a的值为1。但是线程A的工作内存中还是0,由于线程A在之前就已经读取了a的值,执行a++之后再次将a的值刷新到主存,也就是说,a++执行了两次,但两次都是从0变为1。所以a的值最终为1。这里有个槽点,之前说volatile修饰的属性,每次读取都是最新的值,这里线程B执行a++之后,线程A里怎么还是0?应该是1啊!我觉得这是volatile一个比较鸡肋的地方,volatile修饰的属性,如果在修改之前已经读取了值,那么修改之后,无法改变已经复制到工作内存的值。体会一下~
synchronized
synchronized保证原子性、可见性和有序性。用来修饰方法或者代码块。下面是synchronized的一些规则。
- 根据锁对象的不同,一把锁同时最多只能被一个线程持有。
- 如果目标锁已经被当前线程持有,其它线程只能阻塞等待其它线程释放目标锁。
- 如果当前线程已经持有了目标锁,其他线程仍然可以调用目标类中没有被synchronized修饰的方法。
以上规则对下文的Lock同样适用。
锁对象举例
synchronized修饰方法或者synchronized(this)
public class Test
static SyncTest test1 = new SyncTest();
static SyncTest test2 = new SyncTest();
public static void main(String[] args)
new Thread(new Runnable()
@Override
public void run()
test1.syncTwo();
).start();
try
Thread.sleep(1000);
catch (InterruptedException e)
e.printStackTrace();
test1.syncOne();
public class SyncTest
public synchronized void syncOne()
System.out.println("ThreadId : " + Thread.currentThread().getId());
System.out.println("one");
public void syncTwo()
synchronized (this)
int a =0;
while(true)
a++;
try
Thread.sleep(1000);
catch (InterruptedException e)
e.printStackTrace();
System.out.println("ThreadId : " + Thread.currentThread().getId());
if(a == 5) break;
System.out.println("two");
控制台输出:
ThreadId : 10
ThreadId : 10
ThreadId : 10
ThreadId : 10
ThreadId : 10
two
ThreadId : 1
one
为了先调用test1.syncTwo()
,这里先将主线程暂停1s。从控制台输出可以发现,子线程确实被阻塞了,从而说明synchronized修饰方法或者synchronized(this),获得的都是实例对象的锁。
如果将test1.syncOne()
换成test2.syncOne();
那么主线程就不会阻塞了。控制台输出为:
ThreadId : 1
one
ThreadId : 10
ThreadId : 10
ThreadId : 10
ThreadId : 10
ThreadId : 10
two
synchronized(xxx.class)/synchronized static
public class SyncTest
public synchronized static void syncOne()
System.out.println("ThreadId : " + Thread.currentThread().getId());
System.out.println("one");
public void syncTwo()
synchronized (SyncTest.class)
int a = 0;
while (true)
a++;
try
Thread.sleep(1000);
catch (InterruptedException e)
e.printStackTrace();
System.out.println("ThreadId : " + Thread.currentThread().getId());
if (a == 5)
break;
System.out.println("two");
如果将SyncTest中的synchronized(this)
换成synchronized(SyncTest.class)/synchronized static
。那么无论调用test1.syncOne()
还是test2.syncOne()
,主线程都会阻塞。synchronized(SyncTest.class)/synchronized static
这种写法,保证对TestSync类的访问,同一时刻只能有一个线程持有锁。
synchronized实现的是阻塞型并发,synchronized修饰的范围越大,瓶颈越高。为了解决这种问题,由此又有减小锁范围、减小锁粒度和锁分段之说。鉴于篇幅,详细还请自行查看。
CAS
CAS(compare and swap),即比较并交换。synchronized锁住的代码块,同一时刻只能由一个线程访问。属于悲观锁。相对于这种需要挂起线程的悲观锁,还一种由CAS实现的乐观锁。CAS包含三个部分:
- 内存地址A
- 预期旧值B
- 预期新值C
在进行CAS操作时,首先比较A和B,如果相等,则更新A中的值为C并返回true。否则,返回false。通常CAS伴随着死循环,以不断尝试更新的方式实现并发。伪代码如下:
public boolean compareAndSwap(long memoryA, int oldB, int newC)
if(memoryA.get() == oldB)
memoryA.set(newC);
return true;
return false;
相对于synchronized省去了挂起线程、恢复线程的开销,但是如果迟迟得不到更新,死循环对CPU资源也是一种浪费。
使用CAS有个“先检查后执行”的操作,而这种操作在Java中是典型的不安全的操作,所以CAS在实际中是由C++通过调用CPU指令实现的。CAS在Java中的体现为Unsafe类,Unsafe类会通过C++直接获取到属性的内存地址,接下来CAS由C++的Atomic::cmpxchg
方法实现。这个方法会在CPU指令中添加lock指令,而带有lock前缀的指令在执行期间会锁住总线,使得其它处理器暂时无法通过总线访问内存。具体可参考这篇文章。我觉得CAS相对于synchronize,本质上也是一种阻塞的实现。只是阻塞的粒度(CPU指令级别)更小。
AQS
AQS(AbstractQueuedSynchronizer)中维护着一个volatile修饰的属性“state”和一个双向链表,通过使用Unsafe中CAS对“state”属性的一些列骚操作(实际就是把state当做标志位)实现独占锁和共享锁,独占锁和共享锁又分为公平锁和非公平锁。
- 独占锁:同一时刻只有一个线程持有同一锁,其余线程在链表中排队。
- 共享锁:同一时刻可以多个线程持有同一锁。
- 公平锁:锁被线程持有后,其余线程排队执行。锁按照FIFO放入链表。
- 非公平锁:锁被线程持有后,其余线程排队执行。锁按照FIFO放入链表。但是在刚释放锁的之后,如果有新线程竞争锁,那么新线程将和链表中下个即将被唤醒的线程竞争锁。
关于AQS,我找到两篇比较好的文章,这里就不赘述了。想深入了解的可以看下源码。
深度解析Java 8:JDK1.8 AbstractQueuedSynchronizer的实现分析(上)
深度解析Java 8:AbstractQueuedSynchronizer的实现分析(下)
concurrent
JDK在java/util/concurrent提供了很多常用的并发类及并发容器类。并发类基本是通过lock(CAS/AQS)实现,并发容器基本是通过synchronize和lock(CAS/AQS)实现的。这是一篇基础,有机会再慢慢补齐这些。作者水平有限,如有错误,接受有偿指正~
以上是关于Java 并发基础学习的主要内容,如果未能解决你的问题,请参考以下文章