前言
今天进入Java多线程系列的内容,首先我们看这样一个例子。
public class Demo {
private int num=0;
public void increase(){
num++;
}
public int getNum(){
return this.num;
}
public static void main( String[] args ){
Demo demo = new Demo();
for( int i=0; i<10000; i++ ){
demo.increase();
}
System.out.println(demo.getNum());
}
}
// 输出结果是 10000
看起来没有问题,然后现在我们因为效率太低,需要多线程来提高效率,这个时候我们的代码如下:
public class Demo {
private int num=0;
public void increase( ){
this.num = num+1;
}
public int getNum(){
return this.num;
}
public static void main( String[] args ) throws Exception {
final Demo demo = new Demo();
for( int i=0; i<1000; i++ ){
Thread t = new Thread(){
public void run(){
for( int j=0; j<1000; j++ ){
demo.increase();
}
}
};
t.start();
}
while(Thread.activeCount()>1){
Thread.yield();
}
System.out.println(demo.getNum());
}
}
输出结果:968681
可以看到用1000个线程执行,每个线程执行1000次,结果却不是1000000,那么问题出在哪里呢。那么就引出了今天的主题——并发编程的特性。
问题分析
我们首先要了解一下JVM的基础知识,内存结构。
这里可以看到,cpu为了提高速度,都有自己的高速缓存,这就导致,每个线程执行操作的都是变量副本,这就出现了不同线程之前“变量不可见”。如果副本跟主存之间同步不及时,就会出现覆盖的问题,最终导致结果比预期低。
volatile关键字
针对这个问题,为了支持多线程JVM提供了 volatile 关键字。这个关键字可以保证缓存中的变量及时刷新到主存中,并且其他使用这边变量的缓存失效。保证了内存可见性。
所以我们在num前面加上volatile关键字看看结果。
public class Demo {
private volatile int num=0;
public void increase( ){
this.num = num+1;
}
public int getNum(){
return this.num;
}
public static void main( String[] args ) throws Exception {
final Demo demo = new Demo();
for( int i=0; i<1000; i++ ){
Thread t = new Thread(){
public void run(){
for( int j=0; j<1000; j++ ){
demo.increase();
}
}
};
t.start();
}
while(Thread.activeCount()>1){
Thread.yield();
}
System.out.println(demo.getNum());
}
}
// 输出结果:982232
原子操作类——java.concurrent.Atomic.*
我们发现结果有上升,但是还是错误的输出结果,那这是怎么回事呢。我们继续分析,既然已经满足了内存可见性,那么问题只能出在计算的时候。联想到 this.num = num+1; 这个操作需要三步,
1、读取num的值;2、计算num+1的值;3、将结果覆盖num的值。如果程序在这个过程中间失去时间片,而其他线程就修改了num的值,这就导致结果覆盖问题了。这就引出了并发编程的另外一个特性——原子性。
为了应对原子性的问题,java提供了专门的包来保证原子性。代码如下:
public class Demo {
private volatile AtomicInteger num=new AtomicInteger(0);
public void increase( ){
num.incrementAndGet();
}
public int getNum(){
return this.num.intValue();
}
public static void main( String[] args ) throws Exception {
final Demo demo = new Demo();
for( int i=0; i<1000; i++ ){
Thread t = new Thread(){
public void run(){
for( int j=0; j<1000; j++ ){
demo.increase();
}
}
};
t.start();
}
while(Thread.activeCount()>1){
Thread.yield();
}
System.out.println(demo.getNum());
}
}
同步锁——synchronized
到此似乎这个问题得到了解决,那么还有没有其他方式解决原子性的问题呢,其实上面出问题主要是因为执行 this.num = num+1的过程中,其他线程访问了increase方法,我们还可以通过java提供的synchronized 关键字来保证这段代码不能同时有线程访问。代码如下
public class Demo {
private volatile int num=0;
public synchronized void increase( ){
this.num = num+1;
}
public int getNum(){
return this.num;
}
public static void main( String[] args ) throws Exception {
final Demo demo = new Demo();
for( int i=0; i<1000; i++ ){
Thread t = new Thread(){
public void run(){
for( int j=0; j<1000; j++ ){
demo.increase();
}
}
};
t.start();
}
while(Thread.activeCount()>1){
Thread.yield();
}
System.out.println(demo.getNum());
}
}
//输出结果:1000000
JVM重排序
上面解决了可见性和原子性的问题,但是在运行的过程中又出现这样一个问题。
class Singleton {
private static Singleton instance;
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
上面代码运行发现有时候返回的instance类不正确,不能正常使用。我们分析上面代码,synchronized 保证了同时只有一个线程执行这里的创建操作。所以是满足原子性的,同时synchronized 包裹的变量也会实时刷新到主存中,在这里也满足可见性,(注意只是在这个例子里面,后面我会讲解)。那么为什么会出现问题呢。这里就涉及到JVM的指令重排了。
这个例子中,getInstance() 方法使用了双重检查锁定模式(DCL)来实现单例。这种方式的目的是为了减少同步开销,只有当 instance 为 null 时,才会进入同步代码块。
然而,这个例子在多线程环境下可能导致问题,原因在于 instance = new Singleton() 这一行。这里的操作实际上可以分为以下三个步骤:
分配内存空间。
初始化 Singleton 对象。
将 instance 指向分配的内存空间。
由于JVM允许指令重排,步骤2和步骤3可能会发生重排。这将导致以下情况:
线程A执行到 instance = new Singleton(),并完成了内存分配和指令重排,但还没有完成对象初始化。
线程B执行到 if (instance == null),发现instance已经指向了一个内存地址,因此不为null。此时线程B返回了一个未完成初始化的 Singleton 对象。
为了解决这个问题,JVM在volatile 关键字也添加了禁止指令重排的能力,具体的实现原理后面会讲。
总结
通过以上几个例子,我们发现并发编程有三大特性,分别为,
原子性,可见性,顺序性, 以及解决他们的简单方法,后面还会详细他们解决的原理和作用边界。