Java編譯原理--類加載器

Java語言在剛剛誕生的時候提出過一句著名的口號“一次編寫,到處運行”,這句話充分的表達了開發人員對於衝破平臺界限的渴望,也解釋了Java語言跟平臺無關的設定。

一、概述

類加載過程包括加載、連接和初始化,連接又可以細化爲驗證、準備和解析,除了加載過程可以由程序自定義處理外,其他的過程都是由虛擬機自動處理的;在這個過程中,類是如何被加載到內存中的呢?加載的時候需要加載哪些類呢?這就是本文要討論的內容了--類加載器。

根據虛擬機規範,類加載器的作用包括根據類的全限定名找到定義此類的二進制字節流;將字節流代表的靜態存儲類型轉化爲方法區運行時數據結構;在方法區生成class對象,作爲方法區類的入口。虛擬機規範只是規定了整個加載的過程,但是沒有規定具體從什麼文件中加載字節流,也沒有規定如何加載,什麼時候加載等等,每個類加載器可以根據實際情況具體實現。

 二、類與類加載器

類加載器雖然只是用於類的加載動作,但是類加載器在程序中的作用遠不止加載過程。在Java語言中,如果要定義一個類,不僅需要類本身信息,還需要有類加載器的信息,比如我們要判斷兩個類是否相等,不僅要判斷這兩個類是否來源於同一個class文件,還要判斷是否是同一個類加載器加載的,因爲每個類加載器都會有一個獨立的命名空間,不同的類加載器加載的類也是不同的。

上文所指的"相等",不僅包括Class對象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回結果,也包括使用instanceof關鍵字做對象的關係所屬判定等,如果沒有注意類加載器的影響,那麼結果可能會是錯誤的。舉例說明:

package com.jvm.classloader;

import java.io.IOException;
import java.io.InputStream;

public class ClassLoaderTest {

	public static void main(String[] args) throws Exception {
		ClassLoader classLoader = new ClassLoader() {
			@Override
			public Class<?> loadClass(String name) throws ClassNotFoundException {
				try {
					String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
					InputStream is = getClass().getResourceAsStream(fileName);
					if (is == null) {
						return super.loadClass(name);
					}
					byte[] b = new byte[is.available()];
					is.read(b);
					return defineClass(name, b, 0, b.length);
				} catch (IOException e) {
					throw new ClassNotFoundException(name);
				}
			}
		};
		Object o = classLoader.loadClass("com.jvm.classloader.ClassLoaderTest").newInstance();
		System.out.println(o.getClass());
		System.out.println(o instanceof com.jvm.classloader.ClassLoaderTest);
	}
}

輸出結果:

上述代碼構造了一個簡單的類加載器,它可以加載統一路徑下的class文件,我們使用這個類加載器加載類,然後將這個類實例化,打印這個類,可以看到確實是目前所在類,但是判斷這個類和class對象關係,卻發現是false,這是爲什麼呢?因爲main方法再啓動的時候已經使用了類加載器了,這個類加載器是應用類加載器,它已經將class文件加載進虛擬機,自定義類加載器生成的類跟應用類加載器生成的類是不相等的,所以返回false。

三、類加載機制

 1、類加載器分類

從Java虛擬機的角度來考慮,類加載器總共有兩種,一種是啓動類加載器,這個類加載器用C++實現,是虛擬機自身的一部分;另一種是其他類加載器,這些類加載器由Java語言實現,獨立於虛擬機,並且全部繼承自抽象淚java.lang.ClassLoader。

從開發人員的角度來考慮,Java虛擬機還可以劃分的更加詳細,Java虛擬機在功能上可以分爲:啓動類加載器、擴展類加載器、應用類加載器、自定義類加載器。

啓動類加載器負責將存放在jre/lib目錄中的jar加載到虛擬機中,或者被-Xbootclasspath參數所指定的路徑中的jar,並且這些jar是需要被虛擬機識別的(按照文件名識別,例如rt.jar,名字不一致不會被加載)。啓動類加載器無法被程序直接引用,用戶自定義類加載器如果需要引用啓動類加載器,那麼直接將父類加載器設置爲null即可。

擴展類加載器負責將存放在jre/lib/ext目錄下的jar加載到虛擬機中,或者被java.ext.dirs系統變量所指定的目錄下的路徑中的類庫,開發者可以直接使用這個類加載器。

應用類加載器負責將classpath路徑下的類庫,開發者可以直接使用這個類加載器,這個類加載器也是用戶程序默認的類加載器。這個類加載器是ClassLoader的getSystemClassLoader()方法返回的類加載器,所以也被稱爲系統類加載器。

Java虛擬機提供了這麼多的類加載器,我們在程序中改怎麼選擇類加載器呢?如果一個類被多個類加載器加載如何處理呢?如何保證一個類在程序中只能被一個類加載器加載一次呢?這個問題就是虛擬機的類加載機制--雙親委派模型。

2、雙親委派模型

Java虛擬機的類加載採用父類委託機制,這種類加載器之間的層次關係,稱爲雙親委派模型。雙親委派模型要求除了頂層的類加載器之外,其他的類加載器都要有父類加載器,這裏的類加載器的父子關係不是普通Java語言的繼承(Inheritance)關係,而是通過組合(Composition)關係實現的,雙親委派模型如下圖所示:


    雙親委派模型的工作機制:
    1) 如果一個類加載器接到了加載類的請求,它首先不會自己去嘗試加載這個類,而是將加載類的請求委託給父類加載器,父類加載器再次向上委託,一直委託到啓動類加載器;
    2) 如果啓動類加載器可以加載這個類,那麼加載結束;如果啓動類加載器不能加載這個類,則將加載請求轉交給子類加載器,也就是擴展類加載器;
    3) 如果一直到最底層的類加載器依然沒有將這個類加載到內存,則拋出ClassNotFoundException異常,加載過程結束。

3、雙親委派模型的好處

使用雙親委派模型來組織加載類,有着顯而易見的好處,上文說過,Java類的確定需要類本身信息和類加載器的信息,雙親委派模型可以讓類具有了一種優先級的層次關係,例如object這個類,他存放在rt.jar包中,不管哪個類加載器要加載這個類,都需要向上委託,一直委託到啓動類加載器,由啓動類加載器加載,因此,在所有程序中,這個類總是唯一的。如果不適用雙親委派模型,各個類加載器都可以任意加載類的話,那麼用戶就可以自定義一個java.lang.object類和一個類加載器,這時候加載到虛擬機的object會有多個,並且各不相同,Java類型體系就無法保證準確性,應用程序就會出現不可預知的錯誤。

雙親委派模型對於類加載過程和虛擬機來說非常重要,但是它的實現卻很簡單,Java虛擬機爲我們提供了一個抽象類java.lang.ClassLoader,這個類定義了類加載器的基本方法,其中loadClass()方法就是類加載器的入口,這個入口實現了類加載器的雙親委派過程,代碼如下:

protected Class<?> loadClass(String name, boolean resolve)
      throws ClassNotFoundException
  {
      synchronized (getClassLoadingLock(name)) {
          // 先從緩存查找該class對象,找到就不用重新加載
          Class<?> c = findLoadedClass(name);
          if (c == null) {
              long t0 = System.nanoTime();
              try {
                  if (parent != null) {
                      //如果找不到,則委託給父類加載器去加載
                      c = parent.loadClass(name, false);
                  } else {
                  //如果沒有父類,則委託給啓動加載器去加載
                      c = findBootstrapClassOrNull(name);
                  }
              } catch (ClassNotFoundException e) {
                  // ClassNotFoundException thrown if class not found
                  // from the non-null parent class loader
              }

              if (c == null) {
                  // If still not found, then invoke findClass in order
                  // 如果都沒有找到,則通過自定義實現的findClass去查找並加載
                  c = findClass(name);

                  // this is the defining class loader; record the stats
                  sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                  sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                  sun.misc.PerfCounter.getFindClasses().increment();
              }
          }
          if (resolve) {//是否需要在加載時進行解析
              resolveClass(c);
          }
          return c;
      }
  }


    1) 首先檢查要加載的類是否已在內存中存在,如果已存在,即已經加載過則不再重複加載;
    2) 如果沒有加載過則判斷當前的類加載器是否有父類加載器,如果有父類加載器,則嘗試使用父類加載器加載,如果沒有父類加載器,則嘗試使用啓動類加載器加載;
    3) 如果父類加載器和啓動類加載器都沒有加載成功,則嘗試使用此類加載器進行加載;
    4) 如果需要進行解析,則進行類解析,加載過程結束。

四、Tomcat類加載器

Tomcat目錄結構中,有四類目錄需要類加載器進行加載,分別是"/common/*"、"/server/*"、"/shared/*"、"/WEB-INF/*",這四類目錄分別存放Java類庫及各種組件等,Java類庫存放的含義分別是:
    1) 放置在common目錄下的jar包,可以被Tomcat和所有web應用程序共享;
    2) 放置在server目錄下的jar包,可以被Tomcat本身使用,但是不能被其他web應用程序使用;
    3) 放置在shared目錄下的jar包,可以被web應用程序使用,但是不能被Tomcat本身使用;
    4) 防止在webapp/WEB-INF/目錄下的jar包,僅僅可以被當前目錄下的web應用程序使用,不能被其他WEB-INF目錄下的web應用程序使用。
    爲了支持這些目錄結構,並且對目錄結構中的類進行加載和隔離,Tomcat自定義了多種類加載器,這些類加載器按照經典的雙親委派模型來實現,他們之間的關係如下圖所示:
    

上層三個類加載器是Java虛擬機提供的類加載器,這裏不再贅述。而CommonClassLoader、CatalinaClassLoader、SharedClassLoad和WebAppClassLoader則是Tomcat提供的類加載器,他們分別加載commmon、server、shared和webapp目錄下的類庫,其中,WebApp類加載器和JasperLoader會存在多個實例,每個應用程序會生成一個類加載器,每個jsp文件會對應一個類加載器。WebAppClassLoader的作用範圍是當前的應用程序,而JasperClassLoader的作用範圍僅僅是當前的jsp文件,當服務器檢測到有新的jsp文件存在或者當前jsp文件更新時,會重新生成一個JasperClassLoader,這樣就可以保證jsp文件更新之後總是可以及時加載到內存中,從而實現文件熱部署。
    當應用檢測到需要加載某個類時,Tomcat會以以下規則加載類:
    1) 使用啓動類加載器嘗試進行進行加載,如果加載成功,則返回,加載過程結束;
    2) 如果啓動類加載器加載失敗,則使用擴展類加載器嘗試進行加載,加載成功則返回,加載過程結束;
    3) 如果加載失敗,則嘗試使用應用類加載器從WEB-INF/classes目錄下加載,加載成功則直接返回,加載過程結束;
    4) 如果加載失敗,則使用應用類加載器在目錄WEB-INF/lib目錄下加載,加載成功則直接返回,加載過程結束;
    5) 如果加載使用,則嘗試使用Common類加載器在目錄CATALINA_HOME/lib目錄下加載,加載成功則直接返回,加載過程結束;
    6) 如果加載失敗,則拋出異常,加載過程結束。
    Tomcat類加載順序如下圖所示:

基於以上規則,如果想要在web應用程序間共享一個jar包,那麼不僅需要將共享的jar包放置在/common/目錄下,還需要刪除tomcat/lib和WEB-INF/lib目錄下的jar包;

也有可能出現以下問題:
    1) 如果用戶在CATALINA_HOME/lib和WEB-INF/lib目錄中放置了不同版本的jar包,此時可能會導致加載不到類的錯誤,因爲類加載有先後順序;
    2) 如果多個應用使用了同一個jar包,當在應用中放置多個jar包時,有可能導致多個應用間類加載出現問題。

 五、線程上下文類加載器

雙親委派模型並不是強制性的約束模型,而是Java設計者推薦給開發者的類加載器的實現方式,在Java程序世界裏,大部分的類加載器都遵循這個規律,比如Tomcat類加載器,但也有例外,比如下文要提到的線程上下文類加載器。

雙親委派模型非常好的解決了各個類加載器對於基礎類的加載問題,基礎類之所以稱爲基礎類,是因爲他們總是被用戶程序調用(例如java.lang.Object),但是也有例外,如果基礎類要回調應用類呢?例如JDBC服務,JDBC服務是Java的基礎服務,Java只是提供了基本的接口,具體實現需要各個廠商來完成。JDBC接口由啓動類加載器來加載,但是它的實現由各個提供商來實現,JDBC的目的就是完成對數據庫驅動的發現和管理,所以需要在程序啓動時加載這些實現,但是這些實現一定會在用戶程序中,也就是會存放在classpath目錄下,啓動類加載器不能加載這個目錄下的類,這怎麼辦呢?線程上下文類加載器應運而生。

轉自CSDN
    作者:小楊Vita
    鏈接:https://blog.csdn.net/yangcheng33/article/details/52631940
    來源:CSDN
    著作權歸作者所有,轉載請聯繫作者獲得授權

 

前言

此前我對線程上下文類加載器(ThreadContextClassLoader,下文使用TCCL表示)的理解僅僅侷限於下面這段話:

Java 提供了很多服務提供者接口(Service Provider Interface,SPI),允許第三方爲這些接口提供實現。常見的 SPI 有 JDBC、JCE、JNDI、JAXP 和 JBI 等。

這些 SPI 的接口由 Java 核心庫來提供,而這些 SPI 的實現代碼則是作爲 Java 應用所依賴的 jar 包被包含進類路徑(CLASSPATH)裏。SPI接口中的代碼經常需要加載具體的實現類。那麼問題來了,SPI的接口是Java核心庫的一部分,是由啓動類加載器(Bootstrap Classloader)來加載的;SPI的實現類是由系統類加載器(System ClassLoader)來加載的。引導類加載器是無法找到 SPI 的實現類的,因爲依照雙親委派模型,BootstrapClassloader無法委派AppClassLoader來加載類。

而線程上下文類加載器破壞了“雙親委派模型”,可以在執行線程中拋棄雙親委派加載鏈模式,使程序可以逆向使用類加載器。

一直困惱我的問題就是,它是如何打破了雙親委派模型?又是如何逆向使用類加載器了?直到今天看了jdbc的驅動加載過程才茅塞頓開,其實並不複雜,只是一直沒去看代碼導致理解不夠到位。

JDBC案例分析

我們先來看平時是如何使用mysql獲取數據庫連接的:

// 加載Class到AppClassLoader(系統類加載器),然後註冊驅動類
// Class.forName("com.mysql.jdbc.Driver").newInstance(); 
String url = "jdbc:mysql://localhost:3306/testdb";    
// 通過java庫獲取數據庫連接
Connection conn = java.sql.DriverManager.getConnection(url, "name", "password"); 

上就是mysql註冊驅動及獲取connection的過程,各位可以發現經常寫的Class.forName被註釋掉了,但依然可以正常運行,這是爲什麼呢?這是因爲從Java1.6開始自帶的jdbc4.0版本已支持SPI服務加載機制,只要mysql的jar包在類路徑中,就可以註冊mysql驅動。

那到底是在哪一步自動註冊了mysql driver的呢?重點就在DriverManager.getConnection()中。我們都是知道調用類的靜態方法會初始化該類,進而執行其靜態代碼塊,DriverManager的靜態代碼塊就是:

static {
    loadInitialDrivers();
    println("JDBC DriverManager initialized");
}

始化方法loadInitialDrivers()的代碼如下:

private static void loadInitialDrivers() {
    String drivers;
    try {
        // 先讀取系統屬性
        drivers = AccessController.doPrivileged(new PrivilegedAction<String>() {
            public String run() {
                return System.getProperty("jdbc.drivers");
            }
        });
    } catch (Exception ex) {
        drivers = null;
    }
    // 通過SPI加載驅動類
    AccessController.doPrivileged(new PrivilegedAction<Void>() {
        public Void run() {
            ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
            Iterator<Driver> driversIterator = loadedDrivers.iterator();
            try{
                while(driversIterator.hasNext()) {
                    driversIterator.next();
                }
            } catch(Throwable t) {
                // Do nothing
            }
            return null;
        }
    });
    // 繼續加載系統屬性中的驅動類
    if (drivers == null || drivers.equals("")) {
        return;
    }

    String[] driversList = drivers.split(":");
    println("number of Drivers:" + driversList.length);
    for (String aDriver : driversList) {
        try {
            println("DriverManager.Initialize: loading " + aDriver);
            // 使用AppClassloader加載
            Class.forName(aDriver, true,
                    ClassLoader.getSystemClassLoader());
        } catch (Exception ex) {
            println("DriverManager.Initialize: load failed: " + ex);
        }
    }
}

從上面可以看出JDBC中的DriverManager的加載Driver的步驟順序依次是: 
1. 通過SPI方式,讀取 META-INF/services 下文件中的類名,使用TCCL加載; 
2. 通過System.getProperty("jdbc.drivers")獲取設置,然後通過系統類加載器加載。 
下面詳細分析SPI加載的那段代碼。

JDBC中的SPI

先來看看什麼是SP機制,引用一段博文中的介紹:

SPI機制簡介 
SPI的全名爲Service Provider Interface,主要是應用於廠商自定義組件或插件中。在java.util.ServiceLoader的文檔裏有比較詳細的介紹。簡單的總結下java SPI機制的思想:我們系統裏抽象的各個模塊,往往有很多不同的實現方案,比如日誌模塊、xml解析模塊、jdbc模塊等方案。面向的對象的設計裏,我們一般推薦模塊之間基於接口編程,模塊之間不對實現類進行硬編碼。一旦代碼裏涉及具體的實現類,就違反了可拔插的原則,如果需要替換一種實現,就需要修改代碼。爲了實現在模塊裝配的時候能不在程序裏動態指明,這就需要一種服務發現機制。 Java SPI就是提供這樣的一個機制:爲某個接口尋找服務實現的機制。有點類似IOC的思想,就是將裝配的控制權移到程序之外,在模塊化設計中這個機制尤其重要。 
SPI具體約定 
Java SPI的具體約定爲:當服務的提供者提供了服務接口的一種實現之後,在jar包的META-INF/services/目錄裏同時創建一個以服務接口命名的文件。該文件裏就是實現該服務接口的具體實現類。而當外部程序裝配這個模塊的時候,就能通過該jar包META-INF/services/裏的配置文件找到具體的實現類名,並裝載實例化,完成模塊的注入。基於這樣一個約定就能很好的找到服務接口的實現類,而不需要再代碼裏制定。jdk提供服務實現查找的一個工具類:java.util.ServiceLoader

知道SPI的機制後,我們來看剛纔的代碼:

ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
Iterator<Driver> driversIterator = loadedDrivers.iterator();

try{
    while(driversIterator.hasNext()) {
        driversIterator.next();
    }
} catch(Throwable t) {
// Do nothing
}

注意driversIterator.next()最終就是調用Class.forName(DriverName, false, loader)方法,也就是最開始我們註釋掉的那一句代碼。好,那句因SPI而省略的代碼現在解釋清楚了,那我們繼續看給這個方法傳的loader是怎麼來的。

因爲這句Class.forName(DriverName, false, loader)代碼所在的類在java.util.ServiceLoader類中,而ServiceLoader.class又加載在BootrapLoader中,因此傳給 forName 的 loader 必然不能是BootrapLoader,複習雙親委派加載機制請看:java類加載器不完整分析 。這時候只能使用TCCL了,也就是說把自己加載不了的類加載到TCCL中(通過Thread.currentThread()獲取,簡直作弊啊!)。上面那篇文章末尾也講到了TCCL默認使用當前執行的是代碼所在應用的系統類加載器AppClassLoader。

再看下看ServiceLoader.load(Class)的代碼,的確如此:

public static <S> ServiceLoader<S> load(Class<S> service) {
    ClassLoader cl = Thread.currentThread().getContextClassLoader();
    return ServiceLoader.load(service, cl);
}

ContextClassLoader默認存放了AppClassLoader的引用,由於它是在運行時被放在了線程中,所以不管當前程序處於何處(BootstrapClassLoader或是ExtClassLoader等),在任何需要的時候都可以用Thread.currentThread().getContextClassLoader()取出應用程序類加載器來完成需要的操作。

到這兒差不多把SPI機制解釋清楚了。直白一點說就是,我(JDK)提供了一種幫你(第三方實現者)加載服務(如數據庫驅動、日誌庫)的便捷方式,只要你遵循約定(把類名寫在/META-INF裏),那當我啓動時我會去掃描所有jar包裏符合約定的類名,再調用forName加載,但我的ClassLoader是沒法加載的,那就把它加載到當前執行線程的TCCL裏,後續你想怎麼操作(驅動實現類的static代碼塊)就是你的事了。

好,剛纔說的驅動實現類就是com.mysql.jdbc.Driver.Class,它的靜態代碼塊裏頭又寫了什麼呢?是否又用到了TCCL呢?我們繼續看下一個例子。

使用TCCL校驗實例的歸屬

com.mysql.jdbc.Driver加載後運行的靜態代碼塊:

static {
    try {
        // Driver已經加載到TCCL中了,此時可以直接實例化
        java.sql.DriverManager.registerDriver(new com.mysql.jdbc.Driver());
    } catch (SQLException E) {
        throw new RuntimeException("Can't register driver!");
    }
}

registerDriver方法將driver實例註冊到系統的java.sql.DriverManager類中,其實就是add到它的一個名爲registeredDrivers的靜態成員CopyOnWriteArrayList中 。

到此驅動註冊基本完成,接下來我們回到最開始的那段樣例代碼:java.sql.DriverManager.getConnection()。它最終調用了以下方法:

private static Connection getConnection(
     String url, java.util.Properties info, Class<?> caller) throws SQLException {
     /* 傳入的caller由Reflection.getCallerClass()得到,該方法
      * 可獲取到調用本方法的Class類,這兒調用者是java.sql.DriverManager(位於/lib/rt.jar中),
      * 也就是說caller.getClassLoader()本應得到Bootstrap啓動類加載器
      * 但是在上篇文章[java類加載器不完整分析]中講到過啓動類加載器無法被程序獲取,所以只會得到null
      */
     ClassLoader callerCL = caller != null ? caller.getClassLoader() : null;
     synchronized(DriverManager.class) {
         // 此處再次獲取線程上下文類加載器,用於後續校驗
         if (callerCL == null) {
             callerCL = Thread.currentThread().getContextClassLoader();
         }
     }

     if(url == null) {
         throw new SQLException("The url cannot be null", "08001");
     }

     SQLException reason = null;
     // 遍歷註冊到registeredDrivers裏的Driver類
     for(DriverInfo aDriver : registeredDrivers) {
         // 使用線程上下文類加載器檢查Driver類有效性,重點在isDriverAllowed中,方法內容在後面
         if(isDriverAllowed(aDriver.driver, callerCL)) {
             try {
                 println("    trying " + aDriver.driver.getClass().getName());
                 // 調用com.mysql.jdbc.Driver.connect方法獲取連接
                 Connection con = aDriver.driver.connect(url, info);
                 if (con != null) {
                     // Success!
                     return (con);
                 }
             } catch (SQLException ex) {
                 if (reason == null) {
                     reason = ex;
                 }
             }

         } else {
             println("    skipping: " + aDriver.getClass().getName());
         }

     }
     throw new SQLException("No suitable driver found for "+ url, "08001");
 }
private static boolean isDriverAllowed(Driver driver, ClassLoader classLoader) {
    boolean result = false;
    if(driver != null) {
        Class<?> aClass = null;
        try {
        // 傳入的classLoader爲調用getConnetction的線程上下文類加載器,從中尋找driver的class對象
            aClass =  Class.forName(driver.getClass().getName(), true, classLoader);
        } catch (Exception ex) {
            result = false;
        }
    // 注意,只有同一個類加載器中的Class使用==比較時纔會相等,此處就是校驗用戶註冊Driver時該Driver所屬的類加載器與調用時的是否同一個
    // driver.getClass()拿到就是當初執行Class.forName("com.mysql.jdbc.Driver")時的應用AppClassLoader
        result = ( aClass == driver.getClass() ) ? true : false;
    }

    return result;
}

可以看到這兒TCCL的作用主要用於校驗存放的driver是否屬於調用線程的Classloader。例如在下文中的tomcat裏,多個webapp都有自己的Classloader,如果它們都自帶 mysql-connect.jar包,那底層Classloader的DriverManager裏將註冊多個不同類加載器的Driver實例,想要區分只能靠TCCL了。


     

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章