Educoder/头歌JAVA——JAVA高级特性:多线程基础(3)线程同步

目录

第1关:并发编程的三个概念

任务描述

相关知识

1.原子性

2.可见性

3.有序性

编程要求

第2关:使用synchronized关键字同步线程 

相关知识

并发编程什么时候会出现安全问题

怎么解决线程的安全问题呢?

synchronized关键字

synchronized代码块

编程要求

第3关:使用线程锁(Lock)实现线程同步

相关知识

Lock接口

lock()方法的正确使用

编程要求

第4关:使用volatile实现变量的可见性

相关知识

什么是 volatile 关键字

volatile可以保证原子性吗

编程要求


第1关:并发编程的三个概念

任务描述

在我们进行应用开发的时候,常常会关注网站的并发,如果网站的用户量很多,当这些用户同时访问一个服务的时候,我们的服务器就会接收到大量的并发请求,处理好这些并发请求是一个合格程序员必须要完成的工作。

理解并发编程的三个概念对于我们更好的开发高并发的Web应用有很大的帮助。

本关的任务就是理解并发编程的三个重要概念并完成右侧选择题。

相关知识

1.原子性

原子性:即一个操作或者多个操作,要么全部执行并且在执行过程中不会被任何因素打断,要么就不执行。

我们来看看下面这段代码:

  1. x = 10; //语句1
  2. y = x; //语句2
  3. x++; //语句3
  4. x = x + 1; //语句4

现在请你判断,这段代码哪些是原子操作。

可能你会觉得四个语句都是原子操作,可是实际上只有语句1是原子性操作。 语句1是直接将10的值赋值给x,所以也就是说执行这个语句会直接将数值10写入到内存中,所以这是原子性的,语句2其实是两个操作,先读取x的值,然后赋值给y,这两个步骤是原子性的,但是他们合起来就不是原子性操作了,后面两个语句也是同样的道理。

也就是说,只有简单的读取,赋值(必须是将数字赋值给某个变量,变量之间的赋值不是原子性操作),才是原子性操作。

从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现大范围的原子性,可以通过synchronizedlock来实现,lock(锁)和synchronized(同步)在后面的关卡会介绍。

synchronizedlock可以保证在任何时候只有一个线程执行该代码块,所以就保证了原子性。

2.可见性

可见性是当多个线程访问一个变量时,一个线程改变了变量的值,其他线程立马可以知道这个改变。

举个例子:

  1. //线程1执行的代码
  2. int i = 0;
  3. i = 10;
  4. //线程2执行的代码
  5. j = i;

假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行 i =10这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中。

此时线程2执行 j = i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10

这就是可见性问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值。

对于可见性,Java提供了Volatile关键字来保证,当一个变量被Volatile修饰时,它会保证修改的值会被立即重新写入到主内存,当其他线程要调用该共享变量时,会去主内存中重新读取。

但是普通的共享变量是不能保证可见性的,因为普通变量会被读入到线程自己的内存,当一个线程修改了之后,可能还没来得及刷新到主内存,其他线程就从主存中读取了该变量。所以其他线程读取的时候可能还是原来的值,所以普通共享变量是无法保证可见性的。

关于保证可见性还可以通过Synchronizedlock的方式来实现。

3.有序性

有序性:即程序的执行是按照代码编写的先后顺序执行的,例如下面这个例子:

  1. int a;
  2. boolean flag;
  3. i = 10; //语句1
  4. flag = false; //语句2

上述代码定义了一个整形的变量a,布尔类型变量flag,使用语句1和语句2对变量iflag进行赋值,看起来语句1是在语句2之前的,但是在程序运行的时候语句1一定会在语句2之前执行吗?是不一定的,因为这里可能会发生指令重排序Instruction Reorder)。

什么是指令重排序呢?

一般来说,处理器为了提升执行效率,会对输入代码进行优化,它不保证代码执行的顺序和代码编写的顺序一致,但是它会保证程序的输出结果和代码的顺序执行结果是一致的

比如上面的代码中,语句1和语句2谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中,语句2先执行而语句1后执行。

但是要注意,虽然处理器会对指令进行重排序,但是它会保证程序最终结果会和代码顺序执行结果相同,那么它靠什么保证的呢?再看下面一个例子:

  1. int a = 3; //语句1
  2. int b = 5; //语句2
  3. a = a + 3; //语句3
  4. b = b + a + 4; //语句4

上述程序的执行结果就可能是:语句2 => 语句1 => 语句3 => 语句4

那有没有可能是:语句2 => 语句1 => 语句4 => 语句3 呢?

不可能,因为处理器在执行语句的时候会考虑数据之间的依赖性,上述代码语句4是要依赖语句3的结果的,所以处理器会保证语句3在语句4之前执行。

如果一个指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1会在Instruction 2之前执行。

虽然指令重排序不会影响单个线程的最终执行结果,但是多线程的情况下会不会影响呢?我们来看一个例子:

  1. //线程1:
  2. context = loadContext(); //语句1
  3. inited = true; //语句2
  4. //线程2:
  5. while(!inited ){
  6. sleep();
  7. }
  8. doSomethingwithconfig(context);

可以发现语句1和语句2并没有数据依赖性,所以按照指令重排序的规则,可能语句2在语句1之前执行,语句2执行完之后,语句1还没开始执行,可能线程2就开始执行了,这个时候initedtrue,会跳出while循环转而执行doSomethingwithconfig(context)而这个时候语句1还没执行,context还没有初始化,就会造成程序报错。

从上述例子可以看出,指令重排序不会影响单个线程的执行,但是会影响多线程的执行。

也就是说,要保证多线程程序执行的正确性,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。

编程要求

第2关:使用synchronized关键字同步线程 

相关知识

并发编程什么时候会出现安全问题

在单线程的时候是不会出现安全问题的,不过在多线程的情况下就很有可能出现,比如说:多个线程同时访问同一个共享资源,多个线程同时向数据库插入数据,这些时候如果我们不做任何处理,就很有可能出现数据实际结果与我们预期的结果不符合的情况。

例:现在有两个线程同时获取用户输入的数据,然后将数据插入到同一张表中,要求不能出现重复的数据。

我们必然要在插入数据的时候进行如下操作:

  • 检查数据库中是否存在该数据;

  • 如果存在则不插入,否则插入。

现在有两个线程ThreadAThreadB来对数据库进行操作,当某个时刻,线程A和B同时读取到了数据X,这个时候他们都去数据库验证X是否存在,得到的结果都是不存在,然后A、B线程都向数据库插入了X数据,这个时候数据库中出现了两条X数据,还是出现了数据重复。

这个就是线程安全问题,多个线程同时访问一个资源时,会导致程序运行结果并不是想看到的结果

这里面,这个资源被称为:临界资源(也可以叫共享资源)。

当多个线程同时访问临界资源(一个对象,对象中的属性,一个文件,一个数据库等等)时,就有可能产生线程安全问题。

当多个线程执行一个方法时,方法内部的局部变量并不是临界资源,因为方法是在栈上执行的,而Java栈是线程私有的,因此不会产生线程安全问题。

如何解决线程安全问题

怎么解决线程的安全问题呢?

基本上所有解决线程安全问题的方式都是采用“序列化临界资源访问”的方式,即在同一时刻只有一个线程操作临界资源,操作完了才能让其他线程进行操作,也称作同步互斥访问。

在Java中一般采用synchronizedLock来实现同步互斥访问。

synchronized关键字

首先我们先来了解一下互斥锁,互斥锁:就是能达到互斥访问目的的锁。

如果对一个变量加上互斥锁,那么在同一时刻,该变量只能有一个线程能访问,即当一个线程访问临界资源时,其他线程只能等待。

在Java中,每一个对象都有一个锁标记(monitor),也被称为监视器,当多个线程访问对象时,只有获取了对象的锁才能访问。

在我们编写代码的时候,可以使用synchronized修饰对象的方法或者代码块,当某个线程访问这个对象synchronized方法或者代码块时,就获取到了这个对象的锁,这个时候其他对象是不能访问的,只能等待获取到锁的这个线程执行完该方法或者代码块之后,才能执行该对象的方法。

我们来看个示例进一步理解synchronized关键字:

public class Example {
    public static void main(String[] args)  {
        final InsertData insertData = new InsertData();
        new Thread() {
            public void run() {
                insertData.insert(Thread.currentThread());
            };
        }.start();
        new Thread() {
            public void run() {
                insertData.insert(Thread.currentThread());
            };
        }.start();
    }  
}
class InsertData {
    private ArrayList<Integer> arrayList = new ArrayList<Integer>();
    public void insert(Thread thread){
        for(int i=0;i<5;i++){
            System.out.println(thread.getName()+"在插入数据"+i);
            arrayList.add(i);
        }
    }
}

这段代码的执行是随机的(每次结果都不一样):

Thread-0在插入数据0
Thread-1在插入数据0
Thread-1在插入数据1
Thread-1在插入数据2
Thread-1在插入数据3
Thread-1在插入数据4
Thread-0在插入数据1
Thread-0在插入数据2
Thread-0在插入数据3
Thread-0在插入数据4

现在我们加上synchronized关键字来看看执行结果:

public synchronized void insert(Thread thread){
     for(int i=0;i<5;i++){
        System.out.println(thread.getName()+"在插入数据"+i);
        arrayList.add(i);
    }
}
输出:

Thread-0在插入数据0
Thread-0在插入数据1
Thread-0在插入数据2
Thread-0在插入数据3
Thread-0在插入数据4
Thread-1在插入数据0
Thread-1在插入数据1
Thread-1在插入数据2
Thread-1在插入数据3
Thread-1在插入数据4

可以发现,线程1会等待线程0插入完数据之后再执行,说明线程0和线程1是顺序执行的。

从这两个示例中,我们可以知道synchronized关键字可以实现方法同步互斥访问。

在使用synchronized关键字的时候有几个问题需要我们注意:

  1. 在线程调用synchronized的方法时,其他synchronized的方法是不能被访问的,道理很简单,一个对象只有一把锁;

  2. 当一个线程在访问对象的synchronized方法时,其他线程可以访问该对象的非synchronized方法,因为访问非synchronized不需要获取锁,是可以随意访问的;

  3. 如果一个线程A需要访问对象object1synchronized方法fun1,另外一个线程B需要访问对象object2synchronized方法fun1,即使object1object2是同一类型),也不会产生线程安全问题,因为他们访问的是不同的对象,所以不存在互斥问题。

synchronized代码块

synchronized代码块对于我们优化多线程的代码很有帮助,首先我们来看看它长啥样:

  1. synchronized(synObject) {
  2. }

当在某个线程中执行该段代码时,该线程会获取到该对象的synObject锁,此时其他线程无法访问这段代码块,synchronized的值可以是this代表当前对象,也可以是对象的属性,用对象的属性时,表示的是对象属性的锁。

有了synchronized代码块,我们可以将上述添加数据的例子修改成如下两种形式:

class InsertData {
    private ArrayList<Integer> arrayList = new ArrayList<Integer>();
    public void insert(Thread thread){
        synchronized (this) {
            for(int i=0;i<100;i++){
                System.out.println(thread.getName()+"在插入数据"+i);
                arrayList.add(i);
            }
        }
    }
}
class InsertData {
    private ArrayList<Integer> arrayList = new ArrayList<Integer>();
    private Object object = new Object();
    public void insert(Thread thread){
        synchronized (object) {
            for(int i=0;i<100;i++){
                System.out.println(thread.getName()+"在插入数据"+i);
                arrayList.add(i);
            }
        }
    }
}

上述代码就是synchronized代码块添加锁的两种方式,可以发现添加synchronized代码块,要比直接在方法上添加synchronized关键字更加灵活。

当我们用sychronized关键字修饰方法时,这个方法只能同时让一个线程访问,但是有时候很可能只有一部分代码需要同步,而这个时候使用sychronized关键字修饰的方法是做不到的,但是使用sychronized代码块就可以实现这个功能。

并且如果一个线程执行一个对象的非static synchronized方法,另外一个线程需要执行这个对象所属类的static synchronized方法,此时不会发生互斥现象,因为访问static synchronized方法占用的是类锁,而访问非static synchronized方法占用的是对象锁,所以不存在互斥现象。

来看一段代码:

public class Test {
    public static void main(String[] args)  {
        final InsertData insertData = new InsertData();
        new Thread(){
            public void run() {
                insertData.insert();
            }
        }.start(); 
        new Thread(){
            public void run() {
                insertData.insert1();
            }
        }.start();
    }  
}
class InsertData { 
    public synchronized void insert(){
        System.out.println("执行insert");
        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackT\frace();
        }
        System.out.println("执行insert完毕");
    }
    public synchronized static void insert1() {
        System.out.println("执行insert1");
        System.out.println("执行insert1完毕");
    }
}
执行结果:

执行insert
执行insert1
执行insert1完毕
执行insert完毕

编程要求

请仔细阅读右侧代码,根据方法内的提示,在Begin - End区域内进行代码补充,具体任务如下:

  • 使num变量在同一时刻只能有一个线程可以访问。
package step2;

public class Task {

	public static void main(String[] args) {
		
		final insertData insert = new insertData();
		
		for (int i = 0; i < 3; i++) {
			new Thread(new Runnable() {
				public void run() {
					insert.insert(Thread.currentThread());
				}
			}).start();
		}				
	}
}
class insertData{
	
	public static int num =0;
	
	/********* Begin *********/
	public synchronized void insert(Thread thread){
		for (int i = 0; i <= 5; i++) {
			num++;
			System.out.println(num);
		}
	}

	/********* End *********/
}

第3关:使用线程锁(Lock)实现线程同步

相关知识

上一关我们谈到了synchronized关键字,synchronized关键字主要用来同步代码,实现同步互斥访问,也就是在同一时刻只能有一个线程访问临界资源。从而解决线程的安全问题。

如果一个方法或者代码块被synchronized关键字修饰,当线程获取到该方法或代码块的锁,其他线程是不能继续访问该方法或代码块的。

而其他线程要能访问该方法或代码块,就必须要等待获取到锁的线程释放这个锁,而在这里释放锁只有两种情况:

  1. 线程执行完代码块,自动释放锁;

  2. 程序报错,jvm让线程自动释放锁。

可能会有一种情况,当一个线程获取到对象的锁,然后在执行过程中因为一些原因(等待IO,调用sleep方法)被阻塞了,这个时候锁还在被阻塞的线程手中,而其他线程这个时候除了等之外,没有任何办法,我们想一想这样子会有多影响程序的效率。

synchronized是Java提供的关键字,使用起来非常方便,不过在有些情况下,它是具有很多局限性的。因此就需要有一种机制可以不让等待的线程一直无期限地等待下去(比如只等待一定的时间或者能够响应中断),通过Lock就可以办到。

在比如,当多个线程操作同一个文件的时候,同时读写是会冲突的,同时写也是会冲突的,但是同时读是不会发生冲突的,而我们如果用synchronized来实现同步,就会出现一个问题:

如果多个线程都只是进行读操作,所以当一个线程在进行读操作时,其他线程只能等待无法进行读操作。

因此就需要一种机制来使得多个线程都只是进行读操作时,线程之间不会发生冲突,而通过Lock就可以办到。

总的来说Lock要比synchronized提供的功能更多,可定制化的程度也更高,Lock不是Java语言内置的,而是一个类。

Lock接口

我们来了解一下反复提到的Lock,首先我们来查看它的源码:

  1. public interface Lock {
  2. void lock();
  3. void lockInterruptibly() throws InterruptedException;
  4. boolean tryLock();
  5. boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
  6. void unlock();
  7. Condition newCondition();
  8. }

可以发现Lock是一个接口,其中:lock()、tryLock()、tryLock(long time, TimeUnit unit)和lockInterruptibly()方法是用来获取锁的,unlock()方法是用来释放锁的。

首先lock()方法是平常使用得最多的一个方法,就是用来获取锁。如果锁已被其他线程获取,则进行等待。

由于在前面讲到如果采用Lock,必须主动去释放锁,并且在发生异常时,不会自动释放锁。因此一般来说,使用Lock必须在try{}catch{}块中进行,并且将释放锁的操作放在finally块中进行,以保证锁一定被被释放,防止死锁的发生。

一个使用Lock的例子:

  1. Lock lock = ...;
  2. lock.lock();
  3. try{
  4. //处理任务
  5. }catch(Exception ex){
  6. }finally{
  7. lock.unlock(); //释放锁
  8. }

tryLock()顾名思义,是用来尝试获取锁的,并且该方法有返回值,表示获取成功与否,获取成功返回true,失败返回false,从方法可以发现,该方法如果没有获取到锁时不会继续等待的,而是会直接返回值。

tryLock()的重载方法tryLock(long time, TimeUnit unit)功能类似,只是这个方法会等待一段时间获取锁,如果过了等待时间还未获取到锁就会返回false,如果在等待时间之内拿到锁则返回true

所以我们经常这样使用:

  1. Lock lock = ...;
  2. if(lock.tryLock()) {
  3. try{
  4. //处理任务
  5. }catch(Exception ex){
  6. }finally{
  7. lock.unlock(); //释放锁
  8. }
  9. }else {
  10. //如果不能获取锁,则直接做其他事情
  11. }

lock()方法的正确使用

因为Lock是一个接口所以我们在编程时一般会使用它的实现类,ReentrantLockLock接口的一个实现类,意思是“可重入锁”,接下来我们通过一个例子来学习lock()方法的正确使用方式。

示例1:

public class Test {
    private ArrayList<Integer> arrayList = new ArrayList<Integer>();
    public static void main(String[] args)  {
        final Test test = new Test();
        new Thread(){
            public void run() {
                test.insert(Thread.currentThread());
            };
        }.start();
        new Thread(){
            public void run() {
                test.insert(Thread.currentThread());
            };
        }.start();
    }  
    public void insert(Thread thread) {
        Lock lock = new ReentrantLock();    //注意这个地方
        lock.lock();
        try {
            System.out.println(thread.getName()+"得到了锁");
            for(int i=0;i<5;i++) {
                arrayList.add(i);
            }
        } catch (Exception e) {
            // TODO: handle exception
        }finally {
            System.out.println(thread.getName()+"释放了锁");
            lock.unlock();
        }
    }
}
输出:

Thread-1得到了锁
Thread-0得到了锁
Thread-0释放了锁
Thread-1释放了锁

结果可能出乎你的意料,不对呀,按道理应该是一个线程得到锁其他线程不能获取锁了的啊,为什么会这样呢?是因为insert()方法中lock变量是一个局部变量。THread-0Thread-1获取到的是不同的锁,这样不会造成线程的等待。

那怎么才能利用lock()实现同步呢?相信你已经想到了,只要将Lock定义成全局变量就可以了。

public class Test {
    private ArrayList<Integer> arrayList = new ArrayList<Integer>();
    private Lock lock = new ReentrantLock();    //注意这个地方
    public static void main(String[] args)  {
        final Test test = new Test();
        new Thread(){
            public void run() {
                test.insert(Thread.currentThread());
            };
        }.start();
        new Thread(){
            public void run() {
                test.insert(Thread.currentThread());
            };
        }.start();
    }  
    public void insert(Thread thread) {
        lock.lock();
        try {
            System.out.println(thread.getName()+"得到了锁");
            for(int i=0;i<5;i++) {
                arrayList.add(i);
            }
        } catch (Exception e) {
            // TODO: handle exception
        }finally {
            System.out.println(thread.getName()+"释放了锁");
            lock.unlock();
        }
    }
}
结果:

Thread-0得到了锁
Thread-0释放了锁
Thread-1得到了锁
Thread-1释放了锁

这样就是我们预期的结果了。

很多时候我们为了提高程序的效率不希望线程为了等待锁而一直阻塞,这个时候可以使用tryLock()可以达到目的。

示例,将之前的insert()方法修改成tryLock()实现:

 public void insert(Thread thread) {
        if(lock.tryLock()) {
            try {
                System.out.println(thread.getName()+"得到了锁");
                for(int i=0;i<5;i++) {
                    arrayList.add(i);
                }
            } catch (Exception e) {
                // TODO: handle exception
            }finally {
                System.out.println(thread.getName()+"释放了锁");
                lock.unlock();
            }
        } else {
            System.out.println(thread.getName()+"获取锁失败");
        }
}
输出:

Thread-0得到了锁
Thread-1获取锁失败
Thread-0释放了锁

编程要求

请仔细阅读右侧代码,根据方法内的提示,在Begin - End区域内进行代码补充。

package step3;

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

public class Task {

	public static void main(String[] args) {
		final Insert insert = new Insert();
		Thread t1 = new Thread(new Runnable() {
			public void run() {
				insert.insert(Thread.currentThread());
			}
		});
		Thread t2 = new Thread(new Runnable() {
			public void run() {
				insert.insert(Thread.currentThread());
			}
		});
		Thread t3 = new Thread(new Runnable() {
			public void run() {
				insert.insert(Thread.currentThread());
			}
		});
		// 设置线程优先级
		t1.setPriority(Thread.MAX_PRIORITY);
		t2.setPriority(Thread.NORM_PRIORITY);
		t3.setPriority(Thread.MIN_PRIORITY);
		t1.start();
		t2.start();
		t3.start();
	}
}
class Insert {
	public static int num;
	// 在这里定义Lock
    private Lock lock = new ReentrantLock(); 

	public void insert(Thread thread) {
		/********* Begin *********/
		if(lock.tryLock()){
			try{ //处理任务
				System.out.println(thread.getName()+"得到了锁");
				for (int i = 0; i < 5; i++) {
					num++;
					System.out.println(num);
				}
			}finally{
				System.out.println(thread.getName()+"释放了锁");
				lock.unlock();   //释放锁
			}
		}else{ //如果不能获取锁,则直接做其他事情
			System.out.println(thread.getName()+"获取锁失败");
		}
		/********* End *********/
	}
}

第4关:使用volatile实现变量的可见性

相关知识

在并发编程中,volatile关键字扮演着非常重要的作用,接下来我们直接进入主题。

什么是 volatile 关键字

volatile是干啥用的,有什么含义和特点呢?

  1. 当一个共享变量被volatile修饰时,它就具备了“可见性”,即这个变量被一个线程修改时,这个改变会立即被其他线程知道。

  2. 当一个共享变量被volatile修饰时,会禁止“指令重排序”。

先来看个例子:

  1. //线程1
  2. boolean stop = false;
  3. while(!stop){
  4. doSomething();
  5. }
  6. //线程2
  7. stop = true;

因为线程是不直接提供停止的方法的,所以我们很多时候想要中断线程一般都会采用上述代码。

不过这段代码是存在问题的,当线程2执行时,这段代码能保证一定能中断线程1吗?在大多数情况下是可以的,不过也有可能不能中断线程1。

为何有可能导致无法中断线程呢?每个线程在运行过程中都有自己的工作内存,那么线程1在运行的时候,会将stop变量的值拷贝一份放在自己的工作内存当中。

那么当线程2更改了stop变量的值之后,但是还没来得及写入主存当中,线程2转去做其他事情了,那么线程1由于不知道线程2对stop变量的更改,因此还会一直循环下去。

如何避免这种情况呢?

很简单,给stop变量加上volatile关键字就可以了。

volatile关键字会产生什么效果呢?

  1. 使用volatile关键字会强制将变量的修改的值立即写至主内存;

  2. 使用volatile关键字,当线程2对变量stop修改时,会强制将所有用到stop变量的线程对应的缓存中stop的缓存行置为无效。

  3. 由于线程1的stop缓存行无效,所以在运行时线程1会读取主存中stop变量的值。

所以到最后线程1读取到的就是stop最新的值。

volatile可以保证原子性吗

在之前我们了解到了线程的三大特性:原子性,可见性,有序性。

前面的例子我们知道了volatile可以保证共享变量的可见性,但是volatile可以保证原子性吗?

我们来看看:

public class Test {
    public volatile int inc = 0;
    public void increase() {
        inc++;
    }
    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }
        while(Thread.activeCount()>1)  //保证前面的线程都执行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

来想一想这段程序的输出结果,然后copy到本地运行看一看效果。

可能我们想的结果应该是:10000,不过最终运行的结果往往达不到10000,可能我们会有疑问,不对啊,上面是对变量inc进行自增操作,由于volatile保证了可见性,那么在每个线程中对inc自增完之后,在其他线程中都能看到修改后的值啊,所以有10个线程分别进行了1000次操作,那么最终inc的值应该是1000*10=10000

前面我们讲到volatile是可以保证可见性的,不过上述程序的错误在于volatile没法保证程序的原子性。

我们知道变量的自增不是原子性的。它包括两个步骤:

1.读取变量的值;

2.给变量的值加1并写入工作内存。

我们想象这样一种情况:线程1在操作inc变量自增的时候可能会遇到这种状况,读取到了inc变量的值,这个时候inc的值为10,还没有进行自增操作时候线程1阻塞了,紧接着线程2对inc变量进行操作,注意这个时候inc的值还是10,线程2对inc进行了自增操作,这个时候inc的值是11,并将这个改变写到主存中,好了,现在线程1恢复了,它并不会去主存中读取inc的值,因为inc已经在它的缓存中了,所以继续进行之前的操作,注意这个时候线程1的缓存中inc的值是10,线程1对inc的值进行加1.inc等于11,然后写入主存。

我们发现两个线程都对inc进行了一轮操作,但是inc的值只增加了1.

可能我们还是会有疑问,不对啊,前面不是保证一个变量在修改volatile变量时,会让缓存行无效吗?然后其他线程去读就会读到新的值,对,这个没错。这个就是上面的happens-before规则中的volatile变量规则,但是要注意,线程1对变量进行读取操作之后,被阻塞了的话,并没有对inc值进行修改。然后虽然volatile能保证线程2对变量inc的值读取是从内存中读取的,但是线程1没有进行修改,所以线程2根本就不会看到修改的值。

理解了这些我们就能明白,这个问题的根源就在于自增操作不是原子性的。

而要解决这个问题就很简单了,让自增操作变成原子性就可以了。

怎么保证原子性呢,怎么让上述代码结果是10000呢?

用我们前两关学习的知识就可以啦,具体代码自己思考吧,毕竟从自己脑子里想出来的才是自己的呢。

编程要求

请仔细阅读右侧代码,根据方法内的提示,在Begin - End区域内进行代码补充。 ####测试说明

预期输出:10000

提示:可以使用两种方式实现原子性,所以本关有多种方式都可以通关。

package step4;

public class Task {
	public volatile int inc = 0;
//请在此添加实现代码
/********** Begin **********/
	public synchronized void increase() {
		inc++;
}
/********** End **********/
	public static void main(String[] args) {
		final Task test = new Task();
		for (int i = 0; i < 10; i++) {
			new Thread() {
				public void run() {
					for (int j = 0; j < 1000; j++)
						test.increase();
				};
			}.start();
		}
		while (Thread.activeCount() > 1) // 保证前面的线程都执行完
			Thread.yield();
		System.out.println(test.inc);
	}
}

猜你喜欢

转载自blog.csdn.net/zhou2622/article/details/128382708