1.线程安全问题
在前面了解过一些java多线程基础之后,现在,我们用多线程来解决一个实际问题。
假定每个线程可以将一个数字加到100000,现在我们用十个线程,同时相加,看看结果是不是1000000?,代码如下:
ackage com.dhb.concurrent.test;
import java.util.concurrent.CountDownLatch;
public class SyncDemo implements Runnable{
private static int count = 0;
static CountDownLatch countDownLatch = new CountDownLatch(10);
public static void main(String[] args) throws InterruptedException{
for(int i=0;i<10; i++) {
Thread t = new Thread(new SyncDemo());
t.start();
}
countDownLatch.await();
System.out.println(count);
}
private void add(){
for (int i = 0; i < 100000; i++) {
count++;
}
countDownLatch.countDown();
}
@Override
public void run() {
add();
}
}
在上述代码中,分别启动了10个线程,现在看看输出结果是不是1000000?
361177
结果跟预想的并不一样,我们再执行一次看看?
294781
每次还不一样。
这就说明,一定遇到了线程安全的问题。即我们定义的count这个成员变量,在10个线程并发访问的过程中,可能出现了脏读,即一个线程还没有写入完成,另外一个线程就读到了这个没写完的结果,这样就导致了最终的结果不为1000000。为此,我们解决这个问题,不得不想到一个关键字,synchronized来解决。
2.synchronized 的使用说明
并发问题,通常需要解决两类问题,一个是互斥,即资源只能同时由一个线程来访问,当这个线程在访问的过程中,其他线程不能访问这个变量。这就是互斥。另外一个问题就是同步,同步主要是解决线程间通信的问题。即线程由于获取不到访问这个变量之前需要的锁资源,就会进入阻塞状态,让出CPU执行权限。那么何时能够重新执行呢,就需要访问资源的线程在执行完成之后进行通知,wait和notify方法就是很好的线程同步方法。
实际上synchronized的英文就是同步的意思,但是比较有意思的是,synchronized主要是解决的互斥问题。即加锁。
我们将代码修改为如下方式:
private void add(){
synchronized (SyncDemo.class) {
for (int i = 0; i < 100000; i++) {
count++;
}
countDownLatch.countDown();
}
}
再次查看执行结果:
1000000
果然,输出的就是想要的结果了。但是,如果我们将sunchronized的代码块修改为如下呢?
private void add(){
synchronized (this) {
for (int i = 0; i < 100000; i++) {
count++;
}
countDownLatch.countDown();
}
}
结果如下:
205975
又不能满足了。这说明,synchronized代码块,括号中锁定的对象,是有讲究的,前面的SyncDemo.class,由于SyncDemo.class是个特殊的对象,只有一个对象。因此多线程访问的时候就会形成互斥。而改成this之后,由于这个类在使用的时候通过new,导致了多个实例,实例与实例之间加索就不能构成互斥关系。
另外,上述代码块也可以与如下情况等价:
private synchronized void add(){
for (int i = 0; i < 100000; i++) {
count++;
}
countDownLatch.countDown();
}
如果方法中除了代码块没有任何内容,那么这种方式与前面的synchronized(this)等价。
此外synchronized(SyncDemo.class)也与如下等价:
private static synchronized void add(){
for (int i = 0; i < 100000; i++) {
count++;
}
countDownLatch.countDown();
}
对,就是将方法改为静态方法,这样锁住的就是类了。我们总结一下:
分类 | 详细分类 | 被锁的对象 | 代码示例 |
---|---|---|---|
方法 | 实例方法 | 类的实例对象 | public synchronized void method(){ … … } |
方法 | 实例方法 | 类对象 | public static synchronized void method() { … … } |
代码块 | 实例对象 | 类的实例对象 | synchronized(this) { … … } |
代码块 | class对象 | 类对象 | synchronized(SyncDemo.class){ … … } |
代码块 | 任意实例对象Object | 实例对象Object | Object lock = new Object(); synchronized(lock){ … … } |
理论上来说,synchronized()的括号中可以是任意对象。但是,需要注意的是, 一般我们最好不要用String和包装类做为被锁定的对象。 这是因为,在jvm中,对这些类进行特殊处理,String类,尤其是G1中要是开启了字符串去重,那么全部jvm中都只有这一个对象。这样会导致许多系统其他的功能受到影响。包装类由于有常量池,也会导致同样的问题,这样你会莫名其妙的感觉系统卡顿。
3.java的内存模型JMM
在前面学习伪共享的时候了解过,操作系统中,实际上CPU与主内存之间存在多级缓存架构。而这些多级高速缓存的速度远远高于主内存的读取速度。其结构如下:
高速缓存和主内存以及CPU的同步关系,需要通过缓存一致性协议来确保数据的一致性。如MESI、MSI等协议。通过这些协议,才能保证各内存高速缓存与主内存的数据一致性。这个模型如下图所示
除了高速缓存之外,为了使CPU运算单元尽可能的充分利用,还会对输入的代码进行优化,其先后顺序会被改变。因此,在实际代码的执行过程中,其先后顺序不一定按照代码顺序来执行。这就是指令的重排序。关于这一点的细节再后续volatile关键字部分进行详细介绍。
那么JAVA实际上也是与这个模型类似,再java虚拟机中,虚拟机做为最外层的容器,其执行的逻辑与这个模型也非常相似。实际上,线程是CPU的最小执行单位,Java的内存模型实际上是对这个模型的抽象。在java中,也分为主内存和工作内存:
- 主内存:java虚拟机规定,所有变量必须在主内存上产生,主内存也等价于是堆区。与前面的模型相比,这里的主内存可能是前面内存的一部分。
- 工作内存:java虚拟机中的每个线程都有自己的工作内存,也就是线程的栈区。与前面的高速缓存相比,线程的工作过程中需要使用高速缓存。线程的工作内存实际上大部分内容在内存中,分配到CPU执行的时候,就会将需要执行的部分放入高速缓存。
那么java主内存和工作内存之间,也需要通过jvm的一些规则来保证数据的一致性。
需要说明的是,这两个模型只用于对比记忆,实际上二者并无直接关系。因为中间还有操作系统层的映射。而对于操作系统是如何在这两个模型之间转换的,还有很多内容本文并未涉及。
java内存模型如下:
在java中,工作内存与主内存的交互,主要通过如下8种活动来进行,每个活动都是原子性的。
- lock(锁定):作用于主内存的变量,一个变量在同一时间只能一个线程锁定,该操作表示这条线成独占这个变量
- unlock(解锁):作用于主内存的变量,表示这个变量的状态由处于锁定状态被释放,这样其他线程才能对该变量进行锁定
- read(读取):作用于主内存变量,表示把一个主内存变量的值传输到线程的工作内存,以便随后的load操作使用
- load(载入):作用于线程的工作内存的变量,表示把read操作从主内存中读取的变量的值放到工作内存的变量副本中(副本是相对于主内存的变量而言的)
- use(使用):作用于线程的工作内存中的变量,表示把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时就会执行该操作
- assign(赋值):作用于线程的工作内存的变量,表示把执行引擎返回的结果赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的字节码指令时就会执行该操作
- store(存储):作用于线程的工作内存中的变量,把工作内存中的一个变量的值传递给主内存,以便随后的write操作使用
- write(写入):作用于主内存的变量,把store操作从工作内存中得到的变量的值放入主内存的变量中
可以看到,上述图种绿色部分就是在工作内存种执行的活动。其他活动则是在主内存种执行。其过程详细如下图:
在每个线程中,其执行的时候的变量,实际上是其主内存中变量的副本。那么如果采用了synchronized,则会用lock操作锁定该变量,之后其他线程并无法访问。之后再进行read、load过程,之后使用或者赋值。
对于两个线程,分别从主内存中读取变量a和b的值,并不一样要read a; load a; read b; load b; 也会出现如下执行顺序:read a; read b; load b; load a; volatile修饰的变量则除外。
上述这些操作,JSR133规定,需要满足如下规则:
- 1.不允许read和load、store和write操作之一单独出现。这两组操作必须成对出现。read之后就必须load,store之后就必须write,不允许读取之后工作内存不接收,或者store之后主内存不接收的情况。
- 不允许一个线程丢弃最近的assign操作。其修改的值,必须同步回主内存。
- 变量只能在主内存中产生。在工作内存中不允许直接使用一个未被初始化的变量。执行use和assign操作之前必须执行load。
- 一个变量在同一时刻只能被一个线程对其进行lock操作,这就实现了互斥性。也是我们使用synchronized的本质。
- 不允许对没有lock的变量执行unlock操作,如果一个线程没有执行lock,那么肯定不允许执行unlock,当然,也不允许对其他的线程lock的变量执行unlock。
- 对一个变量执行unlock之前,必须先把变量同步回主内存中。也就是执行write之后。
4.总结
本文从线程安全问题引出了synchronized的用法。以及java内存模型的简单介绍。当然,synchronized还有可重入,以及底层具体实践和优化的知识也是非常重要的部分。后续对此详细介绍。