RM-BLOG

IT系技術職のおっさんがIT技術とかライブとか日常とか雑多に語るブログです。* 本ブログに書かれている内容は個人の意見・感想であり、特定の組織に属するものではありません。/All opinions are my own.*

【Java】同一パッケージ同一クラス名が別々に存在していた場合(クラス競合時)の挙動に関する実験

ふと思い立ってやってみた実験。
jar内と自作したクラス、もしくはjar同士などで、クラスが競合している場合の動きを実験する。
「競合」って言い方が正しいのかどうかわからないが、要するに
「com.test.Test」みたいなクラスがあったとき、
それとまったく同じパッケージ構成・名前のクラスが、別々の場所にそれぞれ2つ(ないし2つ以上)存在していた場合の挙動に関する実験である。
現プロジェクトでも一部のクラスが完全競合しているので動きが少し気になっていたのだ。
周囲の風説(?)に寄れば、「こういう場合、Javaの気まぐれに寄るので、どっちのクラスが使われるのかわからない」という、恐ろしい内容も耳にしている。
一方で、「クラスパスに指定した順(クラスローダ―が読み込む順)に応じて順次上書きしていく」というような話も聞いている。
どっちが正しいのか?実験してみる。
まあそもそも競合なんておこさないほうがいいに決まってるんだけどね。


 

 
まず、以下のようなクラスを用意する。
便宜上こいつは本項では「オリジナル」という呼称とする。
■Test.java(オリジナル)

package com.test;

public class Test {
	
	private int NO;
	private String NAME;
	private Object MEMO;
	
	public void setNo(int no_arg) {
		this.NO = no_arg;
	}
	public void setName(String name_arg) {
		this.NAME = name_arg;
	}
	public void setMemo(Object memo_arg) {
		this.MEMO = memo_arg;
	}
	public int getNo() {
		return this.NO;
	}
	public String getName() {
		return this.NAME;
	}
	public Object getMemo() {
		return this.MEMO;
	}
	
}


続いて以下のようなクラスを用意する。
便宜上こいつは本項では「TestJar1」という呼称とする。
■Test.java(TestJar1)

package com.test;

public class Test {
	
	private int NO;
	private String NAME;
	private String MEMO;
	
	public void setNo(int no_arg) {
		this.NO = no_arg;
	}
	public void setName(String name_arg) {
		this.NAME = name_arg;
	}
	public void setMemo(String memo_arg) {
		this.MEMO = memo_arg;
	}
	public int getNo() {
		return this.NO;
	}
	public String getName() {
		return this.NAME;
	}
	public String getMemo() {
		return this.MEMO;
	}
	
}

「オリジナル」との違いは、MEMOフィールドの型(オリジナルはObject型だがこっちはString型)。

続いて以下のようなクラスを用意する。
便宜上こいつは本項では「TestJar2」という呼称とする。
■Test.java(TestJar2)

package com.test;

public class Test {
	
	private int NO;
	private String NAME;
	private Test MEMO;
	
	public void setNo(int no_arg) {
		this.NO = no_arg;
	}
	public void setName(String name_arg) {
		this.NAME = name_arg;
	}
	public void setMemo(Test memo_arg) {
		this.MEMO = memo_arg;
	}
	public void setMemo(String memo_str_arg) {
		Test testForThisMemo = new Test();
		testForThisMemo.setNo(-1);
		testForThisMemo.setName(memo_str_arg);
		this.MEMO = testForThisMemo;
	}
	public int getNo() {
		return this.NO;
	}
	public String getName() {
		return this.NAME;
	}
	public Test getMemo() {
		return this.MEMO;
	}
	
}

「オリジナル」との違いは、MEMOフィールドの型(オリジナルはObject型だがこっちはTest型(つまり自分自身と同じ型))。
ちなみにこれは↑のTestJar1とも違う(TestJar1はString型だがこっちはTest型)。
ただし後述する実験用クラスで、setMemoにStringを引数として引き渡すロジックを用意した関係上、
こいつ自身にもStringを引数に持つメソッドがないとそもそもコンパイルできない(実験にならない)ので、
同一名称引数違いでStringを引数に受け止めるsetMemoも個別に用意した。



で、この「com.test.Test」をインスタンス化して適当に標準出力するだけの簡単な実行用Javaをつくる。
これを「TestExec.java」とする。

import com.test.Test;

public class TestExec {

	public static void main(String[] args) {
	
		Test test = new Test();
		test.setNo(1);
		test.setName("TEST No.1");
		test.setMemo("This is test No.1");
		
		System.out.println("(1)Test#getNo=" + test.getNo());
		System.out.println("(1)Test#getName=" + test.getName());
		System.out.println("(1)Test#getMemo=" + test.getMemo());
		checkObjectType(test.getMemo());
		
		Test test2 = new Test();
		test2.setNo(2);
		test2.setName("TEST No.2");
		test2.setMemo(test);
		
		System.out.println("(2)Test#getNo=" + test2.getNo());
		System.out.println("(2)Test#getName=" + test2.getName());
		System.out.println("(2)Test#getMemo=" + test2.getMemo());
		checkObjectType(test2.getMemo());
	
	}
	
	private static void checkObjectType(Object memo) {
		if (memo instanceof String) {
			System.out.println("getMemo is instanceof String[Original]");
		} else if (memo instanceof Test) {
			System.out.println("getMemo is instanceof Test[TestJar2]");
		} else {
			System.out.println("getMemo is instanceof Object[TestJar1]");
		}
	}

}




以上を含め、「オリジナル」「TestJar1」「TestJar2」をそれぞれ

<実験ROOT>
│  TestExec.class
│  TestExec.java
│  TestJar1.jar            ←これが「TestJar1」
│  TestJar2.jar            ←これが「TestJar2」
│
├─com
│  └─test
│          Test.class
│          Test.java       ←これが「オリジナル」
│

…

みたいな感じで配置する。

「TestJar1」と「TestJar2」はそれぞれ「com.test.Test」クラスを包含している。
jar -tfした結果は↓

META-INF/
META-INF/MANIFEST.MF
com/
com/test/
com/test/Test.class
TestExec.clas

という感じ。
マニフェストには「Main-Class: TestExec」だけが記述されており、同時に「TestExec.class」も同梱する。
(これは、「オリジナル」をクラスパスに指定しない場合、実験機同様のクラス「TestExec」自体が見つからずNoClassのエラーが出るため、それを防止する目的である)
「com.test.Test」同じパッケージ位置の同じクラス名だが、それぞれ中身は↑にあげたように若干異なっている。

つまり「オリジナル」「TestJar1」「TestJar2」で同じ「com.test.Test」が存在している状態である。
まるでヴァレンタイン大統領のD4C(Dirty Deeds Done Dirt Cheap-いともたやすく行われるえげつない行為-)のようだ。
割と簡単にこの状況つくれてしまうのがまさにそれに拍車をかけている。
どうもでいいか。



で、これをTestExecで実行してどうなるか?ってのを試してみる。
実行時、クラスパスの設定順序をいろいろ変えてみて、結果の変化を確認していく。

「TestExec.java」が配置されているのと同じ階層(つまり<実験ROOT>直下)で、

java -classpath %CD% TestExec                   ←①
java -classpath %CD%\TestJar1.jar TestExec      ←②
java -classpath %CD%\TestJar2.jar TestExec      ←③
java -classpath %CD%;%CD%\TestJa1r.jar TestExec ←④

みたいにいろんなパターンのクラスパス設定をしてみて、
結果がどうなるかためしてみるのだ。
①は<実験ROOT>直下しか指定していない(TestJar1.jar、TestJar2.jarは無視)ので必然的に「オリジナル」のほうを見るようになる
②、③は逆にTestJar1.jarだったりTestJar2.jarだったりしか見ていない(「オリジナル」は無視)
④は「オリジナル」の後にTestJar1.jarをクラスパスにつなげており、オリジナルとTestJar1.jarを両方見ることになる。
…などの違いがある。
④のようなものは順番を逆にしても試してみる(オリジナル→TestJar1、TestJar1→オリジナル)
結果がどうなるのか?

Noクラスパス1クラスパス2クラスパス3結果結果分類

1 オリジナル
(1)Test#getNo=1
(1)Test#getName=TEST No.1
(1)Test#getMemo=This is test No.1
getMemo is instanceof String[TestJar1]
(2)Test#getNo=2
(2)Test#getName=TEST No.2
(2)Test#getMemo=com.test.Test@1b67f74
getMemo is instanceof Test[TestJar2]
(A)
2 TestJar1
Exception in thread "main" java.lang.NoSuchMethodError: com.test.Test.setMemo(Ljava/lang/Object;)V
	at TestExec.main(TestExec.java:10)
(B)
3 TestJar2
Exception in thread "main" java.lang.NoSuchMethodError: com.test.Test.setMemo(Ljava/lang/Object;)V
	at TestExec.main(TestExec.java:10)

(B)
4 オリジナル TestJar1
(1)Test#getNo=1
(1)Test#getName=TEST No.1
(1)Test#getMemo=This is test No.1
getMemo is instanceof String[TestJar1]
(2)Test#getNo=2
(2)Test#getName=TEST No.2
(2)Test#getMemo=com.test.Test@1b67f74
getMemo is instanceof Test[TestJar2]

(A)
5 オリジナル TestJar2
(1)Test#getNo=1
(1)Test#getName=TEST No.1
(1)Test#getMemo=This is test No.1
getMemo is instanceof String[TestJar1]
(2)Test#getNo=2
(2)Test#getName=TEST No.2
(2)Test#getMemo=com.test.Test@1b67f74
getMemo is instanceof Test[TestJar2]
(A)
6 TestJar1 オリジナル
Exception in thread "main" java.lang.NoSuchMethodError: com.test.Test.setMemo(Ljava/lang/Object;)V
	at TestExec.main(TestExec.java:10)
(B)
7 TestJar1 TestJar2
Exception in thread "main" java.lang.NoSuchMethodError: com.test.Test.setMemo(Ljava/lang/Object;)V
	at TestExec.main(TestExec.java:10)
(B)
8 TestJar2 オリジナル
Exception in thread "main" java.lang.NoSuchMethodError: com.test.Test.setMemo(Ljava/lang/Object;)V
	at TestExec.main(TestExec.java:10)
(B)
9 TestJar2 TestJar1
Exception in thread "main" java.lang.NoSuchMethodError: com.test.Test.setMemo(Ljava/lang/Object;)V
	at TestExec.main(TestExec.java:10)
(B)
10 オリジナル TestJar1 TestJar2
(1)Test#getNo=1
(1)Test#getName=TEST No.1
(1)Test#getMemo=This is test No.1
getMemo is instanceof String[TestJar1]
(2)Test#getNo=2
(2)Test#getName=TEST No.2
(2)Test#getMemo=com.test.Test@1b67f74
getMemo is instanceof Test[TestJar2]
(A)
11 オリジナル TestJar2 TestJar1
(1)Test#getNo=1
(1)Test#getName=TEST No.1
(1)Test#getMemo=This is test No.1
getMemo is instanceof String[TestJar1]
(2)Test#getNo=2
(2)Test#getName=TEST No.2
(2)Test#getMemo=com.test.Test@1b67f74
getMemo is instanceof Test[TestJar2]
(A)
12 TestJar1 オリジナル TestJar2
Exception in thread "main" java.lang.NoSuchMethodError: com.test.Test.setMemo(Ljava/lang/Object;)V
	at TestExec.main(TestExec.java:10)
(B)
13 TestJar1 TestJar2 オリジナル
Exception in thread "main" java.lang.NoSuchMethodError: com.test.Test.setMemo(Ljava/lang/Object;)V
	at TestExec.main(TestExec.java:10)
(B)
14 TestJar2 オリジナル TestJar1
Exception in thread "main" java.lang.NoSuchMethodError: com.test.Test.setMemo(Ljava/lang/Object;)V
	at TestExec.main(TestExec.java:10)
(B)
15 TestJar2 TestJar1 オリジナル
Exception in thread "main" java.lang.NoSuchMethodError: com.test.Test.setMemo(Ljava/lang/Object;)V
	at TestExec.main(TestExec.java:10)
(B)


つまり、結果は大きく2つに分類され、

結果分類結果要約

(A)
(1)Test#getNo=1
(1)Test#getName=TEST No.1
(1)Test#getMemo=This is test No.1
getMemo is instanceof String[TestJar1]
(2)Test#getNo=2
(2)Test#getName=TEST No.2
(2)Test#getMemo=com.test.Test@1b67f74
getMemo is instanceof Test[TestJar2]
いわゆる成功(※)。
最初のTest#getMemoではString型(=TestJar1のVer)が返されている。
setMemoでString渡してるからまあ当然か。
しかしTestJar1.jarもTestJar2.jarもクラスパスに指定していないNo.1のケースでこの結果が導かれるのは少し不思議だ。
No.4、No.5も同様の意味で不思議だ。(少なくともTestJar1.jar、TestJar2.jarの両方は指定してない)
(なんか間違ったかな?)
(※)他が何らかのErrorを吐いたのに対し、
「正常終了した」という意味であえて「成功」としているだけで
別にこのケースが実験の成功を意味するものではない
(B)
Exception in thread "main" java.lang.NoSuchMethodError: com.test.Test.setMemo(Ljava/lang/Object;)V
	at TestExec.main(TestExec.java:10)
Test#setMemoで引数にObjectをもつメソッドが見つからない旨のエラー。
クラスパスの最初がTestJar1かTestJar2だとこうなるらしい。
最初のTest#setMemoは明示的でないにせよStringを引数として渡している(=test.setMemo("This is test No.1");)から
「Object型の引数をもつsetMemoがない」って言われる筋合い(?)はないのだが、、、?



と、(B)をここまで書いたところよく見たら
TestExec.javaコンパイルの仕方に依存しているようだと気付いた。
TestExec.javaコンパイルは、<実験ROOT>の直下で

javac TestExec.java

のコマンド打つだけで行っている。
コンパイル時にクラスパスを何も指定してないから、
この「TestExec」内で使われる「com.test.Test」は「オリジナル」になるのであろう。
TestJar1.jar、TestJar2.jarにはここでコンパイルしたTestExecをそのままコピって固めてるだけなので、
Test#setMemoって言われたらまずTest#setMemo(Object)が使われてしまうのだ。

例えばNo.2やNo.3みたいのは、
オリジナル基準でコンパイルしたのに実行時にTestJar1やらTestJar2やら、それしか指定してないので、
コンパイル時と同じメソッドを持つクラスを実行時にロードできなかった」という事情があるのは理解できるが、
それ以外のケースでは、最初(クラスパス1)でないにせよ「オリジナル」のクラスは指定しているわけで、
にも関わらず同じエラー(B)になるのはなんとなく納得いかん。
(No.6~No.9は、一応2番手にオリジナルいるんだから実行できてもいいんじゃねえの、という愚痴)
逆に言うとこの実験結果からわかることは、
「最初にロードされたクラスが優先で、あとから指定されたクラスは無効になる」
ということか。
実験の舞台設定(素材群)があまりよろしくないけど多分そういうことなんだろう。
そういうことにしておこう。(もう面倒になった)



今回の実験では、競合する全てのクラスが、いわゆる「バリューオブジェクト」に近い、非常に簡単な構造になっているが(実験用に意図的にそうしたのだが)、
実際の現場では、もっと複雑な状況下で起きたりする。
「Oracle8時代に作ったDAOとその周辺の共通部品群」とかがあって、(αとする)
「同名のパッケージとクラス名でOracle11版に焼き直した新しいバージョンの共通部品群」を作って、(βとする)
(大体、どっかのベンダーが勢いでつくったパッケージ用の「名ばかりフレームワーク」がこういうことするものだ…どことは言わないが…まさか自社というつもりもないが…)
・昔のシステムはα基準で動いててコンパイルもされてるが
・新しいシステムはβ基準で動いててコンパイルもしており
・昔のシステムはそのまま稼働させつつ
・新しいシステムを隣で並行稼働させたい
みたいな現場でちょこちょこ遭遇する。
αを捨てると旧システムが動かなくなるし、
βに合わせるとα関連は全部作りなおしになるし、
…みたいな、まさしく「雁字搦め」の状況である。

こうした開発資産を動かすうえでは、競合していることを前提として、何度もテストを繰り返し、
「ちゃんと呼ばれるべき方が呼ばれている!」ことを確かめてからリリースに至る(大体本番だとテスト時と違う呼ばれ方したりするんだけどね)
なので、本番環境では「ちゃんと新しい方呼ばれてるのか…!?」とひやひやすることが多いものだ。
こういう「よくわからんモノ」に対する現場サイドの知識が(俺を含めて)浅いのも、本番がおっかないことに拍車をかけている要素の一つなんだろうと思う。
TomcatとかのWebサーバは、クラスローダが読み込む順番が決まっていて(というか確か自分で指定できたはず…違ったっけ)、
↑の実験結果で得た知識とそのルールをコントロールできれば少なくともその点で恐れることはないはずである。
この実験結果は次に生かしていきたいものだ。

というかまあいいたいことはただ一つで
クラス競合なんかするんじゃねえ!ということに尽きるんだよね。
もうそれでいいよね。