并发环境下会出现什么问题?
上一篇已经测试过,单个请求是能正常执行并且返回的。但是,系统部署在公网上往往不可能一个人使用,因此必须经过并发测试,不求多规范,至少简单的并发测试也是要进行的。
Apifox图形化界面测试十分简单,还能添加变量。如下所示,简单点,两个线程循环两遍。
修改测试代码,Thread.sleep(1000)模拟测试程序需多耗时一秒。编辑一个自增变量(Apifox文档一使用说明,每次请求id+1)
{
"code": "public class Main {\n public static void main(String[] args) throws Exception{ Thread.sleep(1000); System.out.println(\"hello world----自增id {% mock 'increment', 1 %} \");}}"
}
下面给出我的测试结果:
线程1第一轮:
{
"code": "public class Main {\n public static void main(String[] args) throws Exception{ Thread.sleep(1000); System.out.println(\"hello world----自增id 3 \");}}"
}
{
"error": 0,
"reason": "运行成功",
"stderr": "",
"stdout": "hello world----自增id 4 \n"
}
线程1第二轮:
{
"code": "public class Main {\n public static void main(String[] args) throws Exception{ Thread.sleep(1000); System.out.println(\"hello world----自增id 5 \");}}"
}
{
"error": 0,
"reason": "运行成功",
"stderr": "",
"stdout": "hello world----自增id 6 \n"
}
线程2第一轮:
{
"code": "public class Main {\n public static void main(String[] args) throws Exception{ Thread.sleep(1000); System.out.println(\"hello world----自增id 4 \");}}"
}
{
"error": 0,
"reason": "运行成功",
"stderr": "",
"stdout": "hello world----自增id 4 \n"
}
线程2第二轮:
{
"code": "public class Main {\n public static void main(String[] args) throws Exception{ Thread.sleep(1000); System.out.println(\"hello world----自增id 6 \");}}"
}
{
"error": 0,
"reason": "运行成功",
"stderr": "",
"stdout": "hello world----自增id 6 \n"
}
可知四次网络请求,分别发送了3,4,5,6。而执行结果为(4,4)(6,6)。此时已经出现问题了。
Spring默认单例(后发现与本次无关)
用的的应该都知道SpringMVC(SpringBoot还是有的MVC)的Controller,默认是单例的,可以进行如下测试:
先修改一下TestController的代码,输出对象且直接返回:
@RequestMapping(value = "/run")
public String run(@RequestBody JSONObject json){
System.out.println(this+"---"+service);
/*
Answer answer=service.run(json.getString("code"));
if(answer==null)
return "{\"error\":\"IO错误\"}";
else
return JSONObject.toJSONString(answer);
*/
return "";
}
然后重启!!!重启!!!重启!!!再用软件发送HTTP请求查看控制台输出
显而易见,每次网络请求都是同一个Controller对象,同一个对象自动注入的依赖Service必然也是一样的。这也说明了Spring确实默认为单例的。
但是,没有什么关系。。。。。。因为并没有用到这两个对象的成员变量,无线程安全问题。
真正原因
随着代码走到TestService,就可以很容易发现这段代码:
String DIR="d:/javaTest/";
String javaFile=DIR+"Main.java";
String javaClass="Main";
//编译命令
String compileCmd=String.format("javac -encoding utf8 %s -d %s",javaFile,DIR);
//运行命令
String runningCmd=String.format("java -classpath %s %s", DIR, javaClass);
//将代码写入到定义路径下特定的java源文件中
hhh,前面简单实现为例方便,路径以及文件名都是写死的。而编译以及运行都是耗时操作,简单画图说明
id=3的请求到达,写入了磁盘还运行得出结果返回就可能被id=4的请求覆盖掉了java源文件,所以才会出现两个线程都返回4的情况。第二轮同理,5被6覆盖了。
如何解决
并发问题最新想到的肯定是锁机制,使得临界资源操作同步化来保证线程安全。使用synchronized即可,更细粒度的锁考虑过了不可行,这里耗时的三步:写源文件、编译读源文件写class、执行读class,可以看做一个事务,都有涉及到两个临界资源的读操作。因此读写锁也优化不了多大。。。
已开10线程五轮进行测试,并未发现问题。
@Service
public class TestService {
private Boolean javaLock=true;
public Answer run(String code){
String DIR="d:/javaTest/";
String javaFile=DIR+"Main.java";
String javaClass="Main";
//编译命令
String compileCmd=String.format("javac -encoding utf8 %s -d %s",javaFile,DIR);
//运行命令
String runningCmd=String.format("java -classpath %s %s", DIR, javaClass);
synchronized (javaLock) {
//将代码写入到定义路径下特定的java源文件中
FileWriter writer;
try {
File dir = new File(DIR);
if (!dir.exists()) {
dir.mkdir();
}
writer = new FileWriter(javaFile);
writer.write(code);
writer.close();
} catch (IOException e) {
e.printStackTrace();
return null;
}
//编译源文件为class文件
Answer answer = ExecUtil.run(compileCmd, false, true);
System.out.print(answer.getStderr());
//若编译成功即可开始运行
if (answer.getError() == 0) {
answer = ExecUtil.run(runningCmd, true, true);
if (answer.getError() == 0)
answer.setReason(Answer.Success);
else
answer.setReason(Answer.RuntimeError);
System.out.print(answer.getStdout());
} else
answer.setReason(Answer.Error);
return answer;
}
}
}
同步代码(上锁)性能较低,还有其他效率更好的方法。
很容易想到随机给文件命名,但由于java的特性,public class必须和文件名保持一致,故无法随机给文件命名,如果是C/C++就可以随机命名实现。文件名不行?那就改目录呗,只要每个线程操作的不是一个文件就不存在线程安全问题了。我想到的是可以在目录上加一个时间戳。。。
package com.deng.service;
import com.deng.bean.Answer;
import com.deng.util.ExecUtil;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.util.Date;
@Service
public class TestService {
@Value("${java_file.work_dir}")
private String work_dir;
@Value("${java_file.compile}")
private String compile;
@Value("${java_file.running}")
private String running;
public Answer run(String code){
String time="/"+ new Date().getTime()+"/";
String DIR=work_dir+time;
String javaFile=DIR+"Main.java";
String javaClass="Main";
//编译命令
String compileCmd=String.format(compile,javaFile,DIR);
//运行命令
String runningCmd=String.format(running, DIR, javaClass);
//将代码写入到定义路径下特定的java源文件中
FileWriter writer;
File dir=new File(DIR);
try {
dir.mkdir();
writer = new FileWriter(javaFile);
writer.write(code);
writer.close();
} catch (IOException e) {
e.printStackTrace();
return null;
}
//编译源文件为class文件
Answer answer= ExecUtil.run(compileCmd,false,true);
System.out.print(answer.getStderr());
//若编译成功即可开始运行
if(answer.getError()==0) {
answer = ExecUtil.run(runningCmd, true, true);
if(answer.getError()==0)
answer.setReason(Answer.Success);
else
answer.setReason(Answer.RuntimeError);
System.out.print(answer.getStdout());
}
else
answer.setReason(Answer.Error);
//删除两个文件+文件夹,若想要复查代码可以不删,此处需求是在线运行不是判题(虽然保存也是存数据库),故直接删除
new Del(javaFile,DIR+javaClass+".class",dir).start();
return answer;
}
class Del extends Thread{
private File javaFile;
private File classFile;
private File dir;
Del(String javaFile,String classFile,File dir){
this.classFile=new File(classFile);
this.javaFile=new File(javaFile);
this.dir=dir;
}
@Override
public void run() {
javaFile.delete();
classFile.delete();
dir.delete();
}
}
}
application.yml
server:
servlet:
context-path: /
port: 8080
java_file:
work_dir: "d:/javaTest/"
compile: "javac -encoding utf8 %s -d %s"
running: "java -classpath %s %s"
主要改动就是目录加上了时间戳,配置写死改成了从配置文件读取,执行结束新启动一个线程删除两个文件及文件夹。
下一步
本篇解决线程安全的问题,但是线程数量依旧没有进行限制以及其他问题,但总算解决了一个问题。后续还会补充完善代码…………