QA的形式–继续基础篇
1 Q:什么是并发编程
A: 首先,提到并发,不得不提一下并行的概念。
并发指:同一个时间段内多个任务同时在执行。并行指:单位时间内多个任务同时执行。二者的区别主要在于,并发的多个任务在某个单位时间,不一定同时在执行。
在单核CPU的情况下,只能同时执行一个任务,此时多个任务的执行是并发的,一个任务的时间片使用完,让出CPU给其他任务。
在多核CPU配置下,每个核心可以单独跑一个任务,多个任务可以再同一时间同时执行,实现并行。
但是因为实际场景中,线程数往往大于核心的数量。更多的是线程之间让出和占有CPU,属于并发的概念,所以一般称为多线程并发编程。
2 Q:多线程实现,会带来什么安全问题呢
A:多线程的安全问题,主要和共享变量有关。
如下面的例子,线程A和B都可以取到同一个变量count。
如果AB线程对于count只作读取操作的话,那么不会发生安全问题。至少一个线程修改共享资源的时候,就会发生安全问题。
如上图所示,AB线程对count进行++的操作。操作分为三步,获取,计算,保存。由于线程之前切换的未知性,有可能存在以下的执行顺序:
时间 | 线程A | 线程B |
---|---|---|
t1 | 从内存读count到本地变量 | |
t2 | 本线程的count值++ | 从内存读count到本地变量 |
t3 | 写回内存 | 本线程的count值++ |
t4 | 写回内存 |
从操作上,应该是AB线程分别对count进行了一次++操作。如果count初值为0,那么它最终的结果应该是2。
但是如果发生上述表格的先后执行情况,t3时刻,A线程完成计算写回内存为1,但是t4时刻,B线程把计算的结果1写回内存,最终内存中的count为1。
这就需要在线程访问共享变量的时候进行适当的同步。
3 Q:共享变量的内存可见性是什么
A:这就牵涉到了java在处理共享变量时,内存模型以及CPU的知识。
按照刚才的例子,JAVA的内存模型如下:
所有共享变量存放在主内存中,当线程使用变量的时候,会把主内存中的变量复制到自己的工作空间,线程读写变量的时候,操作的是自己工作内存中的变量副本,处理完成之后再更新到主内存当中。
这是一个抽象的概念,了解过计算机组成原理的话,都知道CPU是存在缓存设计的,每个核心有一个一级缓存,所有CPU有一个共享的二级缓存。
如果缓存命中,将不在读取内存中的数据,如果缓存更新,将同步更新内存和各级缓存。
问题就发生在,CPU有一个核心之间不共享的一级缓存,在写回的时候,无法影响到其他核心的一级缓存,此时其他核心如果发生一级缓存命中,读的数据将不是最新:
-
当一个线程A在某一个CPU上,将变量x写成1之后,A所在核心的一级缓存和共享的二级缓存,主内存全部写回X=1;
-
此时,线程B在另一个CPU的核心上,将X改写为2,此时B所在核心的一级缓存和共享的二级缓存,主内存全部写回X=2;注意此时,A所在的核心的一级缓存还是X=1;
-
如果A又要修改X的值,一级缓存命中,读取到的是X=1;
这就是共享变量内存不可见问题,在java中,一般使用volatile关键字来解决。
4 Q:synchronized是什么,好像很常用
A:前面讨论了多线程的安全问题,在多线程下,为了保证数据的一致性,引入了锁的机制,给共享资源上锁,只有拿到锁才可以访问共享资源。
synchronized是java的一个关键字,是java提供的一种原子性的内置锁,java的每一个对象都可以把它当做一个同步锁来使用(可以修饰一个代码块、方法、静态方法、类)。因为是java内置的,使用者看不到的锁,称为内置锁(监视器锁),内置锁是排它锁。
在程序执行到synchronized修饰的代码块,会自动获取内部锁,这时候,其他线程访问该代码块,会被阻塞挂起。拿到内部锁的线程会在正常退出同步代码块或者抛出异常后或者在同步块内调用了该内置锁资源的wait系列方法时释放锁。(状态的切换,查看该系列文章第一篇)
java的线程与操作系统的线程一一对应,所以阻塞一个线程时,需要从用户态切到内核态进行阻塞操作,synchronized的使用会切换上下文,总的来说,是一个开销很大的操作。
上面还讲述了**内存不可见的问题,synchronized也是可以解决的。**其内存语义,本身也是加锁和释放锁的语义。
程序进入synchronized块的内存语义就是把synchronized块内使用到的变量从线程的工作内存中清除,这样一来,同步代码块中使用到该变量时,不会从线程的工作内存中获取,而是从主内存中获取,且在退出同步代码块的时候,会将对共享变量的修改,刷新到主内存。
5 Q:经常看到的volatile可以解决内存可见性,这是怎么回事
A:上面提到了,可以使用synchronized在加锁和释放锁的时候,对工作内存中的共享变量进行清除,解决内存可见性。但是考虑到synchronized会切换线程上下文,带来线程的调度开销是很大的,所以针对内存可见性问题,java提供了一种弱形式的同步,即,使用volatile关键字。
主要功能就是,确保一个变量的更新对其他线程是马上可见的。
实现:
当一个变量被声明为volatile时,线程在写入变量的时候,不会把值缓存在寄存器或者起其他地方,而是会将值刷新回主内存,当其他线程读取该共享变量时,会从主内存重新获取最新值,而不是从线程当前工作内存中获取。
值得注意的是,虽然volatile提供了可见性的保证,但是和synchronized的内部锁机制不同,volatile不保证操作的原子性,所以它的使用场景如下:
- 写入变量值不依赖变量的当前值,如果依赖当前值的话,那么操作不是原子性的(获取-计算-保存)
- 读写变量值时没有加锁,因为加锁本身已经保证了内存可见性,所以不再需要将变量声明为volatile。
6 Q:这里提到了原子性,如果需要把多个操作设置为一个原子操作,应该怎么做呢
A:最简单的办法就是使用synchronized关键字进行同步
public calss Test{
private int value;
public synchronized int getCount(){
return value;
}
public synchronized void inc(){
value++;
}
}
synchronized保证了线程的安全性(内存可见性和原子性),但是因为synchronized是独占锁,不允许多个线程同时调用,所以不存在安全问题,但是降低了并发性,消耗也大。
如果只是为了实现原子性,可以选择在内部使用非阻塞CAS算法实现原子性操作类AtoimicLong。
Q:get方法只有一个操作,为什么也要synchronized进行修饰呢?
A:这里还需要依靠synchronized,保证内存可见性。
Q: CAS又是什么呢?
A:累了累了,下次再说,哈哈哈
参考文献:《java并发编程之美》