前面我们说到多线程带来的风险,其中一个很重要的就是安全性,因为其重要性因此,放到本章来进行讲解,那么线程安全性问题产生的原因,我们这节将从底层字节码来进行分析。
一、问题引出
先看一段代码
package com.roocon.thread.t3; public class Sequence {
private int value; public int getNext(){
return value++;
} public static void main(String[] args) {
Sequence sequence = new Sequence();
new Thread(new Runnable() {
@Override
public void run() {
while (true){
System.out.println(Thread.currentThread().getName()+" "+sequence.getNext());
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
while (true){
System.out.println(Thread.currentThread().getName()+" "+sequence.getNext());
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
while (true){
System.out.println(Thread.currentThread().getName()+" "+sequence.getNext());
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}).start();
}
}
运行结果:仔细发现,出现了两个84,但代码想要的结果是,每个线程每次执行,就在原来的基础上加一。因此,这里就是线程的安全问题。
Thread-0 0
Thread-1 1
Thread-2 2
...
Thread-2 81
Thread-1 82
Thread-0 83
Thread-2 84
Thread-1 84
Thread-0 85
Thread-2 86
解释原因:
return value++; 通过字节码分析,它其实不是原子操作,value = value + 1;首先,要先读取value的值,然后再对value的值加1,最后将value+1后的结果赋值给原来的value。
如果有线程1和线程2,假设value此时为83。
1.线程1读取value的值,为83。
2.线程1对value进行加1操作,得到值是84,但此时cpu被线程2抢走了,线程2还没来得及将计算后的值赋值给原来的value。
3.线程2读取value的值,仍然为83。
4.线程2对value进行加1操作,得到84,此时cpu被线程1抢走了,线程1继续执行赋值操作,将它计算得到的结果值84赋值给value,于是,线程1输出了84。
5.线程2此时再次抢到了cpu执行权,于是,将它计算得到的结果值84赋值给value,最后输出84。
下面来查看字节码文件验证:
继续往下查看字节码文件的getNext方法:
这些指令告诉我们,value++并不是原子操作。其中,getfield就代表读取value这个字段的值,iadd就表示对value值进行加1操作,而putfield就代表将jia1操作得到的值赋值给原来的value。
指令的含义可以查看:https://www.cnblogs.com/dougest/p/7067710.html
二、解决问题
那么,如何解决上面的问题呢?如何保证多线程的安全性问题呢?
最简单的办法就是,加同步锁。
package com.roocon.thread.t3; public class Sequence {
private int value; public synchronized int getNext(){
return value++;
} public static void main(String[] args) {
Sequence sequence = new Sequence();
new Thread(new Runnable() {
@Override
public void run() {
while (true){
System.out.println(Thread.currentThread().getName()+" "+sequence.getNext());
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
while (true){
System.out.println(Thread.currentThread().getName()+" "+sequence.getNext());
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
while (true){
System.out.println(Thread.currentThread().getName()+" "+sequence.getNext());
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}).start();
}
}
运行结果:
Thread-0 0
Thread-1 1
Thread-2 2
...
Thread-0 81
Thread-1 82
Thread-2 83
Thread-0 84
Thread-1 85
Thread-2 86
Thread-0 87
解决线程安全性问题有很多解决方案,因为,如果所有的解决方案都是加同步锁,那么,所谓的多线程并发最后变成了串行了。那么,多线程就显得没意义了。
最后,总结下产生线程安全性问题三个条件:
1.多线程环境下。
2.多个线程共享一个资源。如servlet就不是线程安全的。在它的service方法中操作同一个实例变量,如果多个线程同时访问,由于多个线程共享该变量,因此存在线程安全问题。
3.对线程进行非原子性操作。
三、javap的理解
也许我们很少会使用到javap工具,因为现在有很多好的反编译工具,但是我在此介绍这个工具不是使用它进行反编译,而是查看java编译器为我们生成 的字节码,通过比较字节码和源代码,我们可以发现很多的问题,一个很重要的作用就是了解很多编译器内部的工作机制。
public class Main { public static void main(String[] args) {
String s = "abc";
String ss = "ok"+s+"xyz"+5;
System.out.println(ss);
}
}
在反编译前你当然需要先编译这个类了:javac -g Main.java(使用-g参数是因为要得到下面javap -l时的输出需要使用此选项)
编译完成后,我们在使用不同的选项看看不同的效果:
1.先看看最简单的不带参数的情况:javap Main:
不带参数的情况将打印类的public信息,包括成员和方法
从上面的输出中我们确定了两个知识:如果类没有显示的从其它类派生那么它就是从Object派生;如果没有为类显示的申明构造方法,那么编译器将为之生成一个缺省构造方法(不带参数的构造方法)
2.javap -c Main
前面的和不带参数的输出一样,后面的显示了方法的具体的字节码,从这个输出里面我们又可以了解更多的内容.
从上面的代码很容易看出,虽然在源程序中使用了"+",但在编译时仍然将"+"转换成StringBuilder。因此,我们可以得出结论,在Java中无论使用何种方式进行字符串连接,实际上都使用的是StringBuilder类。
3.javap -l Main
-l参数将显示行号和局部变量表
4.javap -p Main
-p参数将额外的打印public成员和方法的信息,因为这个类没有因此输出相同
这几个参数几乎就可以构成javap的最常使用的集合,最常用的应该还是-c选项,因为可以打印字节码的信息,关于这些字节码的详细涵义在Java 虚拟机规范中定义,感兴趣的可以查看相关的信息!
5.javap -s Main
输出内部类型签名
6.javap -v Main
输出栈大小,方法参数的个数
四、为eclipse配置javap命令
javap命令经常使用来对java类文件来进行反编译,主要用来对java进行分析的工具,在学习Thinking in Java时,因为须要对类文件反编译。以查看jvm究竟对我们写的代码做了哪些优化和处理,比方我看的
使用+=对字符串进行拼接时。jvm的处理方式。
废话不多说。以下直接带上配置的教程:
点击菜单条 Run ---> External tools ---> External tools Configurations... 然后例如以下图点击New
输入:
Name: javap
Locations: 选择jdk的javap.exe文件所在的位置
Working Directory: ${workspace_loc}/${project_name}
Arguments: -classpath bin -c ${java_type_name}
说明:${workspace_loc}表示工作空间所在的路径;
${project_name}表示项目的名称;
${java_type_name}表示所选java文件的类名(全名);
上面的这些变量能够通过每一栏右下方的Variablesbutton去选择。
(关于其它的一些变量读者能够自行去了解)
Arguments的内容: -classpath表示javap命名搜索的类路径(bin表示是相对于项目的相对路径) -c表示这里将生成JVM字节码
例如以下图:
然后点击Run, 可能会出现例如以下的错误:
出现上面那个错误,说明你未选中java文件。然后选择一个java文件。点击javap,查看反编译后的结果。顺便说一下,你们可能不知道配置后的javap命令去那儿点击,看下图就知道去那儿点击javap了:
五、为Idea中添加javap命令
如果将javap命令添加到编译器中查看字节码文件会方便很多,下面介绍如何在idea中添加javap命令:
(1)打开setting菜单,
(2)找到工具中的扩展工具点击打开,
(3)点击左侧区域左上角的绿色加号按钮会弹出如下图这样的一个编辑框,按提示输入,
(4)完成后点击ok,点击setting窗口的apply然后ok,到这里就已经完成了javap命令的添加,
(5)查看已添加的命令并运行:在代码编辑区右键external tool的扩展选项里可以看到刚才添加的命令,点击执行即可。
参考资料:
龙果学院 《java并发编程与实战》