在Android开发或者Java开发中,遇到并发的问题的时候很多。并发带给我们的问题就是,当多个线程操作同一个数据的时候,往往不能得到我们预期的结果。造成这个问题的原因是什么呢?其实就是该数据对这多个线程没有可见性,这些线程就不能有序性的去操作共同数据,还不是原子操作,所以导致预期结果不一样。这之间的一些细节的问题是啥呢?下面我们举例说明:
在说例子之前我们来看一下,机器内存的分配情况以及线程对内存的操作情况:
在上图中可以看到有一个JVM栈也就是java虚拟机栈。每一个线程启动都会有一个线程栈,线程栈保存了线程运行时的变量信息,当线程访问某个值的时候先通过这个值的引用在堆内存中找到这个值,然后load到线程本地内存中,创建一个变量副本,然后就跟这个值本身没有任何关系了,而是对副本进行操作,在某一个时刻(线程结束之前)将修改好的值写入到堆内存中。如下图:
现在有这么一段代码:代码很简单,定义了一个变量count,然后启动了1000个线程去对这个count进行+1操作,我们预期的结果是1000.如果每个线程按顺序,第一个去取得count,进行+1之后写入到主内存中,然后接着第二个去取,进行操作,写入主内存中,再接着第三个。。。。。直到第1000个线程执行完毕。那count就做了1000次+1操作,那结果当然是1000.但是该这段代码运行的结果,有可能是1000,也有可能不是1000,并且大部分时间是不可能是1000的。原因是什么呢?因为这1000个线程是并发的,也就是说有可能线程1取得从堆内存取得count是0,取过来之后就会在自己的线程栈中创建一个副本,操作的是这个副本,直到最后操作完成才会去更改堆内存中的count。那如果线程1还没操作完,还没有去更改堆内存中的count,线程2就去堆内存中取了count,这时候线程2取得的count值还是0,进行+1,操作完成之后得到的结果是1,然后线程1和线程2都操作完成了,这时候他们会将本地栈内存中的count副本的值写入到堆内存中.那肯定他们更改的结果都是1。这时候并发的导致的困扰就出现了,本来两个线程操作完之后,我们希望count应该是2.但是现在却是1.所以最后1000个 线程执行完毕之后,最后的count值可能不是1000,而是不固定的,这就要看有多少个线程发生了并发。当然了这儿还有一个问题,那就是有可能这些线程还没有完全执行完,main方法中就走了打印count值的这句代码,并且addition方法中打印出来的值,有可能重复,并且杂乱无章。
package com.dragon;
public class Run {
public static int count = 0;
public static void main(String[] args0) {
for (int i = 0; i < 1000; i++) {
new Thread(new Runnable() {
@Override
public void run() {
addition();
}
}).start();
}
System.out.print("the reault :" + count + "\n");
}
private static void addition() {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
count++;
System.out.print("addition count :" + count + "\n");
}} 运行结果:
根据上面的解释和代码运行的效果,我们可以得出一个结论,线程并发导致得不到我们预期的效果的主要原因是因为每个线程之间不能确定是谁先执行,也不能确定是什么时候执行,并且他们操作的变量还不是直接对堆内存中的同一个值进行原子操作,而是操作内部的副本,操作完成之后再去改变堆内存中的值。
上面我们引入了原子性,可见性,有序性。都具体是什么玩意儿呢?客观别急,我们坐下来慢慢谈。
原子性:所谓的原子性就是指该操作不可再分,或者说一个操作或者多个操作要么执行,并且不会被打断,要么就不执行。看例子:
1.int x = 10; 2.int y = x ; 3.x++; 4.x = x+1;
这上面这四个语句,那些是原子操作,那些没有原子操作呢?我们来看这四句话都做了些什么:
第一句:直接把10赋值给x的工作内存,这个操作不可再分,并且很直接,这是原子操作。
第二句:先去读取x的值,然后再将x的值写入到y的工作内存,这个可分割,并且不直接,这就不是原子操作
第三句:其实跟第四句一样都是执行x = x+1;线读取x的值,然后进行+1操作,才会将新值写入到x的工作内存,可分割,不是原子操作。
所以可以看出来,所谓的原子操作,原子性就是直接对某一个变量进行操作,中间没有别的操作。
可见性:当线程1更改了变量的值,但是线程2并不知道,这叫不可见,但是如果线程1操作变量值的时候,线程2知道,那就叫可见。所以可见性就是说线程之间操作共同变量的时候,彼此之间都能知道。
有序性:有序性就是指执行代码是有序的去执行,线程1先执行,完了之后线程2才去执行。
好了,上面讲了这么多,差不多知道并发导致的问题是啥了吧?也知道为啥会导致这个结果了吧?那具体怎么去解决呢?那我们来要怎么才能解决并发的问题呢? 要解决并发的问题,就只要解决上面的原子操作,可见性,有序性就解决了并发的问题。
可见性:可见性的意思是说所有访问volatile修饰变量的线程都能知道该变量的改变。
java提供volatile修饰,volatile相当于一个轻量的synchronized修饰符,volatile保证变量的可见性。也即是说不允许线程将volatile修饰的变量缓存到的线程栈中,每一次取值必须在Java堆中取到的数据。但是这样并不能保证线程安全,因为只是解决了可见性,并没有解决原子性。比如说线程1取到count的值是0,正在进行+1操作,但是还没操作完,这时候线程1还没有将count的值改成1,这时候线程2就去去count这个值了,这时候他取到的值还是0,并不是1.
另外synchronized和Lock也能保证可见性,因为共享代码块会被锁起来,如果我们释放锁的时候,是在将线程栈中副本中的值写入到堆内存中之后,那就能保证每一个线程拿到的值都是最新的,这种一般我们用的比较多。
另外volatile使用有一定的限定条件:
1.对该变量的写操作不依赖于该变量的当前值。
2.该变量没有包含在具有其他变量的可变式中。
原子性:上面已经说了什么是原子操作,原子性,并且知道线程操作共享数据是一组操作合在一块儿的,并不是原子操作。那我们怎么才能保证这一组操作是原子操作呢?那还是用到synchronized或者lock,将共享代码上锁,必须等到副本的值写入到堆内存之后才能释放锁给下一个线程使用。
有序性:synchronized和lock的正确使用能达到一个互斥的效果,能保证同一时间只能有一个线程或者可以控制有几个线程在访问共享代码块,从而达到有序性。
好了讲了这么多,最后我们来点代码,看看怎么去解决并发的问题的吧!因为synchronized已经在 《 Java 线程和进程,并发解决之synchronized 》一文中写了,这儿就不说了。我们来说说lock。
更改上面的代码如下:
private static Semaphore semaphore = new Semaphore(1);
private static ReentrantLock lock = new ReentrantLock();
private static int count;
public static void main(String[] args0) {
for (int i = 0; i < 1000; i++) {
new Thread(new Runnable() {
@Override
public void run() {
addition();
}
}).start();
}
System.out.print("the reault :" + count + "\n");
} private static void addition() { try { semaphore.acquire(); lock.lock(); try { Thread.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } count++; System.out.print("addition count :" + count + "\n"); lock.unlock(); semaphore.release(); } catch (Exception e) { e.printStackTrace(); } } }
这时候我们在运行代码就跟之前不一样了,可以看到线程的访问是有序的,并且计算出来的值也是规规矩矩的,没有乱七八糟。效果如图:
最后上面应该能看到我用到了Semaphore类,来控制线程,其实这个类比起synchronized有一些他自己独有的手段,比如说控制并发的数量。在后面我会补上一片更详细的关于Semaphore类的文章。