创建线程的三种方法

  • 实现Runnable接口
  • 继承Thread类
  • 通过callable和Future创建线程

参考

Volatile的作用

volatile提供了一种轻量的同步机制,其最为重要的特点为:

  • 保证可见性
  • 不保证原子性
  • 禁止指令重排

可见性

在多线程程序中,每个线程都会拥有一个属于自己的工作空间(栈空间)。对操作数的操作在该空间进行。但是每个线程所操作的操作数可能是同一个操作数,这个时候就需要用一种同步机制,让任何线程改变操作数的时候,都可以保证其他线程可以知道操作数的最新值。

这里的基本设计就是,每次线程改变操作数的时候,就将操作数写回到多个线程共有的主内存,然后其他线程通过主内存操作数的变化得知操作数已经发生了改变。

为了实现上述的可见性,提出了许多协议。例如MESI协议,要求CPU写公共操作数的时候,会通知其他CPU将其缓存设置为无效;而其他CPU在读的时候,如果发现CPU缓存无效,就读取主内存的值。

那么,上述的通知其他CPU将缓存设置为无效是怎么实现的呢?这里用到了总线嗅探的技术。就是CPU会监控总线传播的数据,如果发现自己缓存对应的内存地址的值发生变化,那么就读取内存地址的值。

不保证原子性

考虑下面的程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class myData{

volatile int count=0;
public void add(){
count++;
}
public myData(){};
}

public class volatileCount {

public static void main(String[] args) {
myData data=new myData();
new Thread(()->{
for(int i=0;i<100000;i++){
data.add();

}
},"aa").start();

new Thread(()->{
for(int i=0;i<100000;i++){
data.add();
}
},"bb").start();
while(Thread.activeCount() > 2) {
Thread.yield();//Thread.yield函数,表明当前调用该函数的线程让出cpu;这里指的是main一直让出cpu
}
System.out.println(data.count);
}
}

上面的程序在执行的时候,两个线程同时对同一个操作数进行操作。由于将变量复制到工作内存了,因此在每次增加变量的值的时候,可能会出现多个线程同时写的情况,比如线程1已经写了,改变了主内存;此时,应该会通知其他线程的缓存无效,但是其他线程已经根据缓存算出了新值,在进行写操作了,然后将值写入内存,这样就出现了数据的丢失。

禁止指令重排

什么是指令重排

指令的顺序可能和程序设定的并不一样。

image-20220907205026465

比如上面的命令就是指令重排的一个例子。

因为指令重排,所以程序不一定可以按照我们的预期执行。比如下面的一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public class ThreadTest {
private static boolean configLoaded = false;

public static void main(String[] args) {
Thread threadA = new Thread(new Runnable() {
@Override
public void run() {
loadConfigFromFile();
configLoaded = true;
}

private void loadConfigFromFile() {
}
});
Thread threadB = new Thread(new Runnable() {
@Override
public void run() {
while (!configLoaded) {
sleep(10);
}
doSomeWork();
}

private void doSomeWork() {
}

private void sleep(int n) {
try {
Thread.sleep(n);
} catch (Exception e) {
e.printStackTrace();
}

}
});
threadA.start();
threadB.start();

}

@Test
public void test() {

}

}

上面的例子中,我们希望在线程A执行完loadConfigFromFile之后,线程B再执行后续工作。程序中通过configLoaded传递信息。但是这里由于在线程A内部,loadConfigFromFile方法和configLoaded并无逻辑关系,因此可能会将configLoaded排在loadConfigFromFIle之前。

我们的解决办法是为configLoaded添加上volatile修饰,该修饰可以禁止将configLoaded赋值语句之后的语句放到configLoaded赋值之前,之前的语句放在configLoaded赋值之后。

禁止指令重排的原理

volatile通过内存屏障实现了禁止指令重排和内存可见性。

内存屏障是一个cpu指令。其第一个作用是告诉编译器和CPU,无论如何都不可重排该条指令。第二个作用是刷新出CPU的缓存。

关于方法中不能定义volatile的解释

1
2
3
4
5
6
7
8
9
10
11
12
13
public class volatileCount {

public static void main(String[] args) {
volatile int count=0;//报错
new Thread(()->{

},"aa").start();

new Thread(()->{

},"bb");
}
}

上面可以看到,如果直接在方法中定义volatile变量是会报错的。这是因为local variable都是定义在stack中的,而volatile只对定义在heap中的变量起作用。因此在方法内部定义volatile无效,因此就禁止这么做了。

这里有一个有趣的现象。如果我们定义一个类,然后类的内部有一个volatile变量,比如下面的程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class myData{

volatile int count=0;
public void add(){
this.count++;
}
public myData(){};
}

public class volatileCount {

public static void main(String[] args) {
myData data=new myData();
new Thread(()->{

},"aa").start();

new Thread(()->{

},"bb");
}
}

我们将volatile变量放到了一个类的内部,然后我们就会发现我们竟然可以在方法中使用这个类了。

我们上面说到了,不能使用volatile int的原因是volatile int 对应的变量是定义在栈区的,但java类的实例可是定义在堆区的。而本地栈实际上只存着 基本类型和对象的引用指针,因此上述的代码不会产生错误。

这里还有一个有趣的测试,就是static变量。在java7 以上的版本中,静态域存储于定义类型的Class对象中,Class对象如同堆中其他对象一样,存在于GC堆中参考。因此,如果将count定义为static变量,程序应该也不会出错。