前言
最近把旧项目的框架更换为Spring boot,打包方式更为jar启动,其实以前也不是war部署,而是通过main方式启动的,这里看看原理。
1. main方式启动的原理
java 命令启动可以-jar也可以直接带main class,那么直接启动带main方法的类即可启动应用
<!-- Assembly plugin -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<executions>
<execution>
<id>bin</id>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
<configuration>
<descriptors>
<descriptor>src/main/assembly/assembly.xml</descriptor>
</descriptors>
</configuration>
</execution>
</executions>
</plugin>
maven有插件可以打包,比如上面的插件,通过一个配置文件如下:
直接配置好class jar与lib复制的对应关系,打成一个zip或者其他,最好封装好start脚本
<assembly xmlns="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.1"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.1 http://maven.apache.org/xsd/assembly-1.1.1.xsd">
<id>bin</id>
<includeBaseDirectory>false</includeBaseDirectory>
<formats>
<format>dir</format>
<format>zip</format>
</formats>
<files>
<file>
<source>../xxx.jar</source>
<destName>../lib/arthas-spy.jar</destName>
</file>
<file>
<source>../xxx/xxx.class</source>
<destName>xxx.class</destName>
</file>
</files>
<fileSets>
<fileSet>
<directory>../xxx</directory>
</fileSet>
<fileSet>
<directory>../lib</directory>
</fileSet>
</fileSets>
</assembly>
打包完成是一个main类一个lib目录,然后java -cp ./lib xxx.class
通过java指令可以看出 cp即classpath:表示这个jar或者zip,目录是classpath的意思
等同于Xbootclasspath,不过Xbootclasspath更细分:/a表示在classpath末尾追加,兼容性好,建议使用;/p表示在classpath前,可能会冲突。
2. java -jar
官方已经定义了java的程序启动class或者-jar,那么-jar是怎么启动呢
其实是META-INF.MANIFEST.MF这个文件定义的
Main-Class
以Spring boot为例,看看Spring boot打包的插件,定义了mainClass,如下
<build>
<finalName>boot-demo</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>2.3.1.RELEASE</version>
<configuration>
<mainClass>com.feng.boot.BootMain</mainClass>
</configuration>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
那么打成的jar呢,居然替换了,不过也是,毕竟没有脚本来-cp lib,classes这些目录,而是一个jar包括所有的启动相关的类 配置等,Spring boot相当于使用自定义代码把这些代码的启动参数模拟出来了。
3. Spring boot启动原理
3.1 加载classes与lib
既然有Main-Class,那么java就会去执行org.springframework.boot.loader.JarLauncher
public class JarLauncher extends ExecutableArchiveLauncher {
public static void main(String[] args) throws Exception {
new JarLauncher().launch(args);
}
因为new会先new parent的构造,会初始化一些路径,可以看出先找到当前类的URI,进而读取文件,这个很重要,关乎于其他classes lib 等的加载。
public ExecutableArchiveLauncher() {
try {
this.archive = createArchive();
this.classPathIndex = getClassPathIndex(this.archive);
}
catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
protected final Archive createArchive() throws Exception {
ProtectionDomain protectionDomain = getClass().getProtectionDomain();
CodeSource codeSource = protectionDomain.getCodeSource();
URI location = (codeSource != null) ? codeSource.getLocation().toURI() : null;
String path = (location != null) ? location.getSchemeSpecificPart() : null;
if (path == null) {
throw new IllegalStateException("Unable to determine code source archive");
}
File root = new File(path);
if (!root.exists()) {
throw new IllegalStateException("Unable to determine code source archive from " + root);
}
//这里非常关键,其实获取jar里面的文件就是通过这里实现的
return (root.isDirectory() ? new ExplodedArchive(root) : new JarFileArchive(root));
}
jar包:JarFileArchive 这个是主要途径
解压缩的目录:ExplodedArchive
使用JDK的ZIpFile读取,JDK1.7开始的,jar即zip文件
Opens a new <code>ZipFile</code> to read from the specified <code>File</code> object in the specified mode.
/**
* Opens a new <code>ZipFile</code> to read from the specified
* <code>File</code> object in the specified mode. The mode argument
* must be either <tt>OPEN_READ</tt> or <tt>OPEN_READ | OPEN_DELETE</tt>.
*
* <p>First, if there is a security manager, its <code>checkRead</code>
* method is called with the <code>name</code> argument as its argument to
* ensure the read is allowed.
*
* @param file the ZIP file to be opened for reading
* @param mode the mode in which the file is to be opened
* @param charset
* the {@linkplain java.nio.charset.Charset charset} to
* be used to decode the ZIP entry name and comment that are not
* encoded by using UTF-8 encoding (indicated by entry's general
* purpose flag).
*
* @throws ZipException if a ZIP format error has occurred
* @throws IOException if an I/O error has occurred
*
* @throws SecurityException
* if a security manager exists and its <code>checkRead</code>
* method doesn't allow read access to the file,or its
* <code>checkDelete</code> method doesn't allow deleting the
* file when the <tt>OPEN_DELETE</tt> flag is set
*
* @throws IllegalArgumentException if the <tt>mode</tt> argument is invalid
*
* @see SecurityManager#checkRead(java.lang.String)
*
* @since 1.7
*/
public ZipFile(File file, int mode, Charset charset) throws IOException
{
if (((mode & OPEN_READ) == 0) ||
((mode & ~(OPEN_READ | OPEN_DELETE)) != 0)) {
throw new IllegalArgumentException("Illegal mode: 0x"+
Integer.toHexString(mode));
}
String name = file.getPath();
SecurityManager sm = System.getSecurityManager();
if (sm != null) {
sm.checkRead(name);
if ((mode & OPEN_DELETE) != 0) {
sm.checkDelete(name);
}
}
if (charset == null)
throw new NullPointerException("charset is null");
this.zc = ZipCoder.get(charset);
long t0 = System.nanoTime();
jzfile = open(name, mode, file.lastModified(), usemmap);
sun.misc.PerfCounter.getZipFileOpenTime().addElapsedTimeFrom(t0);
sun.misc.PerfCounter.getZipFileCount().increment();
this.name = name;
this.total = getTotal(jzfile);
this.locsig = startsWithLOC(jzfile);
}
可以看见文件描述符
org.springframework.boot.loader.jar.JarFile在初始化的时候,就把文件的offset定义好了,Spring boot取文件就是通过offset取文件的,速度极快
private JarFile(JarFile parent, RandomAccessDataFile rootFile, String pathFromRoot, RandomAccessData data,
JarEntryFilter filter, JarFileType type, Supplier<Manifest> manifestSupplier) throws IOException {
super(rootFile.getFile());
super.close();
this.parent = parent;
this.rootFile = rootFile;
this.pathFromRoot = pathFromRoot;
CentralDirectoryParser parser = new CentralDirectoryParser();
this.entries = parser.addVisitor(new JarFileEntries(this, filter));
this.type = type;
parser.addVisitor(centralDirectoryVisitor());
try {
//最关键的是这里
this.data = parser.parse(data, filter == null);
}
catch (RuntimeException ex) {
close();
throw ex;
}
this.manifestSupplier = (manifestSupplier != null) ? manifestSupplier : () -> {
try (InputStream inputStream = getInputStream(MANIFEST_NAME)) {
if (inputStream == null) {
return null;
}
return new Manifest(inputStream);
}
catch (IOException ex) {
throw new RuntimeException(ex);
}
};
}
跟踪parse
RandomAccessData parse(RandomAccessData data, boolean skipPrefixBytes) throws IOException {
CentralDirectoryEndRecord endRecord = new CentralDirectoryEndRecord(data);
if (skipPrefixBytes) {
data = getArchiveData(endRecord, data);
}
RandomAccessData centralDirectoryData = endRecord.getCentralDirectory(data);
visitStart(endRecord, centralDirectoryData);
parseEntries(endRecord, centralDirectoryData);
visitEnd();
return data;
}
获取文件总的offset length,下一步拆分文件的offset position
visitStart,初始化centralDirectory hashCode、Offsets positions
public void visitStart(CentralDirectoryEndRecord endRecord, RandomAccessData centralDirectoryData) {
int maxSize = endRecord.getNumberOfRecords();
this.centralDirectoryData = centralDirectoryData;
this.hashCodes = new int[maxSize];
this.centralDirectoryOffsets = new int[maxSize];
this.positions = new int[maxSize];
}
通过文件头信息分割,可见Spring boot的插件开发者对zip文件的读写原理研究得非常厉害。
private void parseEntries(CentralDirectoryEndRecord endRecord, RandomAccessData centralDirectoryData)
throws IOException {
byte[] bytes = centralDirectoryData.read(0, centralDirectoryData.getSize());
CentralDirectoryFileHeader fileHeader = new CentralDirectoryFileHeader();
int dataOffset = 0;
for (int i = 0; i < endRecord.getNumberOfRecords(); i++) {
fileHeader.load(bytes, dataOffset, null, 0, null);
visitFileHeader(dataOffset, fileHeader);
dataOffset += CENTRAL_DIRECTORY_HEADER_BASE_SIZE + fileHeader.getName().length()
+ fileHeader.getComment().length() + fileHeader.getExtra().length;
}
}
/**
* A ZIP File "Central directory file header record" (CDFH).
*
* @author Phillip Webb
* @author Andy Wilkinson
* @author Dmytro Nosan
* @see <a href="https://en.wikipedia.org/wiki/Zip_%28file_format%29">Zip File Format</a>
*/
final class CentralDirectoryFileHeader implements FileHeader {
解析这段代码非常复杂,笔者看不懂
static long littleEndianValue(byte[] bytes, int offset, int length) {
long value = 0;
for (int i = length - 1; i >= 0; i--) {
value = ((value << 8) | (bytes[offset + i] & 0xFF));
}
return value;
}
void load(byte[] data, int dataOffset, RandomAccessData variableData, int variableOffset, JarEntryFilter filter)
throws IOException {
// Load fixed part
this.header = data;
this.headerOffset = dataOffset;
long nameLength = Bytes.littleEndianValue(data, dataOffset + 28, 2);
long extraLength = Bytes.littleEndianValue(data, dataOffset + 30, 2);
long commentLength = Bytes.littleEndianValue(data, dataOffset + 32, 2);
this.localHeaderOffset = Bytes.littleEndianValue(data, dataOffset + 42, 4);
// Load variable part
dataOffset += 46;
if (variableData != null) {
data = variableData.read(variableOffset + 46, nameLength + extraLength + commentLength);
dataOffset = 0;
}
this.name = new AsciiBytes(data, dataOffset, (int) nameLength);
if (filter != null) {
this.name = filter.apply(this.name);
}
this.extra = NO_EXTRA;
this.comment = NO_COMMENT;
if (extraLength > 0) {
this.extra = new byte[(int) extraLength];
System.arraycopy(data, (int) (dataOffset + nameLength), this.extra, 0, this.extra.length);
}
if (commentLength > 0) {
this.comment = new AsciiBytes(data, (int) (dataOffset + nameLength + extraLength), (int) commentLength);
}
}
关键是visitFileHeader,精准定位文件的hashCode、offset与position
private void add(AsciiBytes name, int dataOffset) {
this.hashCodes[this.size] = name.hashCode();
this.centralDirectoryOffsets[this.size] = dataOffset;
this.positions[this.size] = this.size;
this.size++;
}
然后处理end
public void visitEnd() {
sort(0, this.size - 1);
int[] positions = this.positions;
this.positions = new int[positions.length];
for (int i = 0; i < this.size; i++) {
this.positions[positions[i]] = i;
}
}
读取文件到这里结束,这里就把jar(zip文件)里的文件hashcode、offset与position详细的获取完成,方便后续处理。
那么再看看launch,这里仅仅是把文件处理,并没有加载,加载如下:
protected void launch(String[] args) throws Exception {
if (!isExploded()) {
JarFile.registerUrlProtocolHandler();
}
ClassLoader classLoader = createClassLoader(getClassPathArchivesIterator());
String jarMode = System.getProperty("jarmode");
String launchClass = (jarMode != null && !jarMode.isEmpty()) ? JAR_MODE_LAUNCHER : getMainClass();
launch(args, launchClass, classLoader);
}
关键是,这里获取到各个需要加载的目录了
getClassPathArchivesIterator()
protected Iterator<Archive> getClassPathArchivesIterator() throws Exception {
//函数式编程
Archive.EntryFilter searchFilter = this::isSearchCandidate;
//2个过滤器
Iterator<Archive> archives = this.archive.getNestedArchives(searchFilter,
(entry) -> isNestedArchive(entry) && !isEntryIndexed(entry));
//处理classpath的其他jar或者类 配置
if (isPostProcessingClassPathArchives()) {
archives = applyClassPathArchivePostProcessing(archives);
}
return archives;
}
这里isSearchCandidate,是一个搜索过滤器,条件是:BOOT-INF/开头
protected boolean isSearchCandidate(Archive.Entry entry) {
return entry.getName().startsWith("BOOT-INF/");
}
关键代码,其中一个是前面的,筛选哪些是要加载的。
this.archive.getNestedArchives(searchFilter,
(entry) -> isNestedArchive(entry) && !isEntryIndexed(entry));
isNestedArchive && !isEntryIndexed
static final EntryFilter NESTED_ARCHIVE_ENTRY_FILTER = (entry) -> {
if (entry.isDirectory()) {
return entry.getName().equals("BOOT-INF/classes/");
}
return entry.getName().startsWith("BOOT-INF/lib/");
};
protected boolean isNestedArchive(Archive.Entry entry) {
return NESTED_ARCHIVE_ENTRY_FILTER.matches(entry);
}
判断是否目录,前缀限制
private boolean isEntryIndexed(Archive.Entry entry) {
if (this.classPathIndex != null) {
return this.classPathIndex.containsEntry(entry.getName());
}
return false;
}
classpath索引,比如类或者jar必须包括xxx,默认情况是没有限制的。
进一步跟踪
public Iterator<Archive> getNestedArchives(EntryFilter searchFilter, EntryFilter includeFilter) throws IOException {
return new NestedArchiveIterator(this.jarFile.iterator(), searchFilter, includeFilter);
}
private class NestedArchiveIterator extends AbstractIterator<Archive> {
NestedArchiveIterator(Iterator<JarEntry> iterator, EntryFilter searchFilter, EntryFilter includeFilter) {
super(iterator, searchFilter, includeFilter);
}
关键迭代器,自定义的
private abstract static class AbstractIterator<T> implements Iterator<T> {
private final Iterator<JarEntry> iterator;
private final EntryFilter searchFilter;
private final EntryFilter includeFilter;
private Entry current;
AbstractIterator(Iterator<JarEntry> iterator, EntryFilter searchFilter, EntryFilter includeFilter) {
this.iterator = iterator;
this.searchFilter = searchFilter;
this.includeFilter = includeFilter;
this.current = poll();
}
@Override
public boolean hasNext() {
return this.current != null;
}
@Override
public T next() {
//这个很有意思,直接把当前的'处理'返回
T result = adapt(this.current);
//然后读取一个
this.current = poll();
return result;
}
private Entry poll() {
while (this.iterator.hasNext()) {
//用其他迭代器,然后过滤
JarFileEntry candidate = new JarFileEntry(this.iterator.next());
//条件处理,就是上面说的filter
if ((this.searchFilter == null || this.searchFilter.matches(candidate))
&& (this.includeFilter == null || this.includeFilter.matches(candidate))) {
return candidate;
}
}
return null;
}
protected abstract T adapt(Entry entry);
}
看boot的jar,确实是这样的,当读取完红框的内容就迭代结束
3.2 创建classloader载入
这样就可以把所有Spring boot需要的文件读取到,加载进classloader
protected ClassLoader createClassLoader(Iterator<Archive> archives) throws Exception {
List<URL> urls = new ArrayList<>(guessClassPathSize());
while (archives.hasNext()) {
urls.add(archives.next().getUrl());
}
if (this.classPathIndex != null) {
urls.addAll(this.classPathIndex.getUrls());
}
return createClassLoader(urls.toArray(new URL[0]));
}
public class LaunchedURLClassLoader extends URLClassLoader
protected ClassLoader createClassLoader(URL[] urls) throws Exception {
return new LaunchedURLClassLoader(isExploded(), getArchive(), urls, getClass().getClassLoader());
}
通过META-INF.MANIFEST.MF获取到,Spring包装的main的类
Start-Class: com.feng.boot.BootMain
protected String getMainClass() throws Exception {
Manifest manifest = this.archive.getManifest();
String mainClass = null;
if (manifest != null) {
mainClass = manifest.getMainAttributes().getValue(START_CLASS_ATTRIBUTE);
}
if (mainClass == null) {
throw new IllegalStateException("No 'Start-Class' manifest entry specified in " + this);
}
return mainClass;
}
然后,jarmode,就是运行Spring的定义jar
private static final String JAR_MODE_LAUNCHER = "org.springframework.boot.loader.jarmode.JarModeLauncher";
String jarMode = System.getProperty("jarmode");
String launchClass = (jarMode != null && !jarMode.isEmpty()) ? JAR_MODE_LAUNCHER : getMainClass();
launch(args, launchClass, classLoader);
jarmode,很熟悉的代码。Spring boot的自动装配JarMode
public static void main(String[] args) {
String mode = System.getProperty("jarmode");
List<JarMode> candidates = SpringFactoriesLoader.loadFactories(JarMode.class,
ClassUtils.getDefaultClassLoader());
for (JarMode candidate : candidates) {
if (candidate.accepts(mode)) {
candidate.run(mode, args);
return;
}
}
System.err.println("Unsupported jarmode '" + mode + "'");
if (!Boolean.getBoolean(DISABLE_SYSTEM_EXIT)) {
System.exit(1);
}
}
3.3 launch
回到launch,设置当前线程classloader为LaunchedURLClassLoader,毕竟很多中间件会从线程取classloader
protected void launch(String[] args, String launchClass, ClassLoader classLoader) throws Exception {
Thread.currentThread().setContextClassLoader(classLoader);
createMainMethodRunner(launchClass, args, classLoader).run();
}
最后一步,反射main类的main方法。
public void run() throws Exception {
Class<?> mainClass = Class.forName(this.mainClassName, false, Thread.currentThread().getContextClassLoader());
Method mainMethod = mainClass.getDeclaredMethod("main", String[].class);
mainMethod.setAccessible(true);
mainMethod.invoke(null, new Object[] { this.args });
}
至此Spring boot启动的过程就完结了。
总结
其实java启动程序,官方定义了2种方式main class或者-jar,Spring boot使用-jar模式,自定义了类、配置、jar的路径,并通过loader的jar加载,然后反射我们写的main类的main方法。其实我们也可以自己写个脚本,然后把其他文件打成jar放在lib,配置放在config,然后通过main类指定classpath启动,原理一样。不过Spring boot对文件的解析确实厉害。